From 2024549b55eea01dda2a6cd204fd4ba562b1dbe7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 5 Jun 2026 16:29:35 +0300 Subject: [PATCH 1/9] =?UTF-8?q?docs(roadmap):=20mark=201.L.0=20done=20(PR?= =?UTF-8?q?=20#6=20merged);=20open=20Wave=201=20(1.A=20=E2=80=96=201.L)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 Wave 0 — the @relavium/shared reconciliation (1.L.0) — is merged. Flip the phase status to In progress, mark the 1.L.0 workstream ✅ Done, and rewrite current.md's immediate next steps to the two Wave-1 lanes per the sequencing plan: 1.A (freeze the LLMProvider seam types, scaffold packages/llm) and 1.L (WorkflowYAMLParser, scaffold packages/core). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap/current.md | 41 +++++++++++-------- docs/roadmap/phases/phase-1-engine-and-llm.md | 6 ++- 2 files changed, 28 insertions(+), 19 deletions(-) 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/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index 38d6c697..aa155049 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -1,6 +1,8 @@ # 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 (**1.A** seam ‖ +> **1.L** parser) is next. - **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 +384,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)) From a5aa70732ff0833de479602be071158eb228552d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 5 Jun 2026 16:48:00 +0300 Subject: [PATCH 2/9] feat(llm): freeze the LLMProvider seam types (1.A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 Wave-1 workstream 1.A — scaffold @relavium/llm and freeze the immovable LLMProvider seam in src/types.ts, expressed only in Relavium/Zod types (ADR-0011, llm-provider-seam.md). No provider SDK yet — adapters land in 1.C/1.G/1.H. @relavium/llm (new package, mirrors @relavium/shared: tsc build, catalog deps, types:[] platform-free): - Zod schemas (+ inferred types): LlmRequest, LlmRole/LlmMessage, ToolDef, ToolChoice, Usage, CapabilityFlags, LlmErrorKind/LlmError, LlmResult, StreamChunk (the 6-variant discriminated union). LlmProvider is a TS interface (it carries methods: id + generate(req,key) + stream(req,key) + supports). - ToolDef.parameters is typed JSONSchema7 (z.custom; @types/json-schema is a dev/type-only catalog dep — erased at build, no ADR) — the deep subset reshape is the ToolNormalizer's job (1.E). - Curated index.ts (not export *) re-exporting the seam + the shared substrate. - 19 tests: per-schema accept/reject + type-level pins proving the public types are pure Relavium types (ProviderId set, StopReason set, a stub LlmProvider compiles) — a leaked vendor type would fail them, and the import-zone fence forbids the import outright. @relavium/shared — the seam substrate that the dependency direction (shared → llm) requires to live in shared so the future session schemas can reuse it without a circular import: - content.ts: ContentPartSchema (text | tool_call | tool_result) and AbortSignalLike (the minimal structural cancellation handle — lib:["ES2023"] has no AbortSignal, and a real one satisfies it structurally, so the platform-free packages need no DOM lib / @types/node). - +6 tests (173 total). The seam re-exports ContentPart/StopReason from here. Full gate green (12/12 turbo); seam fence airtight (packages/llm/src/types.ts is fenced — only src/adapters/* may import a vendor SDK); db drift gate unchanged. Refs: ADR-0011 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/llm/README.md | 26 ++-- packages/llm/package.json | 35 +++++ packages/llm/src/index.ts | 41 ++++++ packages/llm/src/types.test.ts | 193 ++++++++++++++++++++++++++++ packages/llm/src/types.ts | 158 +++++++++++++++++++++++ packages/llm/tsconfig.build.json | 9 ++ packages/llm/tsconfig.json | 12 ++ packages/shared/src/content.test.ts | 62 +++++++++ packages/shared/src/content.ts | 47 +++++++ packages/shared/src/index.ts | 1 + pnpm-lock.yaml | 25 ++++ pnpm-workspace.yaml | 3 + 12 files changed, 604 insertions(+), 8 deletions(-) create mode 100644 packages/llm/package.json create mode 100644 packages/llm/src/index.ts create mode 100644 packages/llm/src/types.test.ts create mode 100644 packages/llm/src/types.ts create mode 100644 packages/llm/tsconfig.build.json create mode 100644 packages/llm/tsconfig.json create mode 100644 packages/shared/src/content.test.ts create mode 100644 packages/shared/src/content.ts 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/index.ts b/packages/llm/src/index.ts new file mode 100644 index 00000000..efd3677a --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,41 @@ +/** + * `@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'; diff --git a/packages/llm/src/types.test.ts b/packages/llm/src/types.test.ts new file mode 100644 index 00000000..53ce7f82 --- /dev/null +++ b/packages/llm/src/types.test.ts @@ -0,0 +1,193 @@ +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 + }); + + 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: '', 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..e9c4871c --- /dev/null +++ b/packages/llm/src/types.ts @@ -0,0 +1,158 @@ +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, { + 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. + signal: z.custom().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 From 7ca81b324b94761fe1db9a88ce992648f6854c2f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 5 Jun 2026 17:38:23 +0300 Subject: [PATCH 3/9] feat(llm): CostTracker + model pricing (1.B) and ToolNormalizer (1.E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pure-TS Wave-1 workstreams on the Relavium side of the seam (no provider SDK; the fence forbids vendor imports outside src/adapters/*). Both build on the frozen 1.A seam types. 1.B — CostTracker + pricing: - pricing.ts: the canonical MODEL_PRICING table keyed on canonical model id — the in-code source of truth the adapters and the tracker share, seeded later into the (empty) model_catalog. Prices are integer micro-cents per million tokens (USD x 1e8) — the DB unit, no float. Anthropic rows confirmed via claude-api (Opus 4.8 $5/$25, Sonnet 4.6 $3/$15, Haiku 4.5 $1/$5, + cache read/write); OpenAI/Gemini/DeepSeek are best-known placeholders (VERIFY at 1.G/1.H, tracked). - cost-tracker.ts: priceModel (unknown id -> typed UnknownModelError, never a silent zero), cost(modelId, usage) = round(tokens x ratePerMtok / 1e6) per token class billed once (inputTokens net of cache reads), and a CostTracker accumulator producing the cost:updated figures across per-attempt usage. - errors.ts: typed UnknownModelError + ToolSchemaError (discriminated `code`). 1.E — ToolNormalizer: - toWire(toolDef, providerId): one canonical ToolDef -> the three native wire shapes (OpenAI/ DeepSeek function, Anthropic input_schema, Gemini functionDeclarations) as Relavium-defined plain objects — no vendor type. - reshapeForGemini: the OpenAPI-subset reshape — allow-lists the keywords Gemini honors (incl. anyOf and the numeric/string bounds), strips the rest, drops unsupported `format` values, and THROWS a typed ToolSchemaError on a `$ref` it cannot express. - GeminiToolCallIds + normalizeToolCall: synthesize stable tool-call ids (Gemini has none) and match functionResponses FIFO per name; fold an extracted {id,name,args} into a canonical tool_call ContentPart, rejecting an empty id/name from a misbehaving provider. Verified by a 3-skeptic adversarial workflow (cost arithmetic, ToolNormalizer, seam/fence/spec). Cost dimension clean; fixed the findings it surfaced: the Gemini reshape was over-stripping `anyOf` (destroying unions) and the numeric/string bound keywords — now kept; normalizeToolCall now rejects empty ids; and llm-provider-seam.md §6 is clarified to state `inputTokens` is net of cache reads (the OpenAI/DeepSeek adapter subtracts the cached subset), matching the CostTracker. +20 llm tests (39 total). Full gate green; seam fence airtight; db drift gate unchanged. Refs: ADR-0011 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared-core/llm-provider-seam.md | 8 + docs/roadmap/deferred-tasks.md | 10 + packages/llm/src/cost-tracker.test.ts | 80 +++++++ packages/llm/src/cost-tracker.ts | 79 +++++++ packages/llm/src/errors.ts | 53 +++++ packages/llm/src/index.ts | 24 ++ packages/llm/src/pricing.ts | 146 ++++++++++++ packages/llm/src/tool-normalizer.test.ts | 181 +++++++++++++++ packages/llm/src/tool-normalizer.ts | 210 ++++++++++++++++++ 9 files changed, 791 insertions(+) create mode 100644 packages/llm/src/cost-tracker.test.ts create mode 100644 packages/llm/src/cost-tracker.ts create mode 100644 packages/llm/src/errors.ts create mode 100644 packages/llm/src/pricing.ts create mode 100644 packages/llm/src/tool-normalizer.test.ts create mode 100644 packages/llm/src/tool-normalizer.ts 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/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/packages/llm/src/cost-tracker.test.ts b/packages/llm/src/cost-tracker.test.ts new file mode 100644 index 00000000..b7ae646e --- /dev/null +++ b/packages/llm/src/cost-tracker.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { CostTracker, cost, priceModel } from './cost-tracker.js'; +import { UnknownModelError } from './errors.js'; +import { KNOWN_MODEL_IDS } 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); + }); +}); diff --git a/packages/llm/src/cost-tracker.ts b/packages/llm/src/cost-tracker.ts new file mode 100644 index 00000000..77cef879 --- /dev/null +++ b/packages/llm/src/cost-tracker.ts @@ -0,0 +1,79 @@ +import { UnknownModelError } from './errors.js'; +import { 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 { + const pricing: ModelPricing | undefined = (MODEL_PRICING as Record)[ + modelId + ]; + if (pricing === undefined) { + throw new UnknownModelError(modelId, KNOWN_MODEL_IDS); + } + return pricing; +} + +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 index efd3677a..974597fa 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -39,3 +39,27 @@ export type { // 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..d57117f4 --- /dev/null +++ b/packages/llm/src/pricing.ts @@ -0,0 +1,146 @@ +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), matching the DB's `*_per_mtok_microcents` columns — no float, ever. + * + * 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.0), + outputPerMtokMicrocents: usd(25.0), + 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.0), + outputPerMtokMicrocents: usd(15.0), + 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.0), + outputPerMtokMicrocents: usd(5.0), + 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.0), + 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.0), + 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; + +/** Every canonical model id, for diagnostics (e.g. the unknown-model error). */ +export const KNOWN_MODEL_IDS = Object.keys(MODEL_PRICING) as readonly CanonicalModelId[]; diff --git a/packages/llm/src/tool-normalizer.test.ts b/packages/llm/src/tool-normalizer.test.ts new file mode 100644 index 00000000..786b2e61 --- /dev/null +++ b/packages/llm/src/tool-normalizer.test.ts @@ -0,0 +1,181 @@ +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 { AnthropicToolWire, GeminiToolWire, OpenAiToolWire } 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 }; + +describe('toWire — one canonical ToolDef, three native shapes', () => { + it('shapes the Anthropic input_schema form (parameters passed through)', () => { + const wire = toWire(toolDef, 'anthropic') as AnthropicToolWire; + 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') as OpenAiToolWire; + 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') as GeminiToolWire; + 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 = reshaped['properties'] as Record>; + 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 = (reshaped['properties'] as Record>)['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 = reshaped['properties'] as Record>; + 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' } }, + } as 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'); + } + } + }); +}); + +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..2ce9874e --- /dev/null +++ b/packages/llm/src/tool-normalizer.ts @@ -0,0 +1,210 @@ +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(['enum', 'date-time', 'int32', 'int64', 'float', 'double']); + +function reshapeNode(node: unknown, toolName: string): unknown { + if (Array.isArray(node)) { + return node.map((entry) => reshapeNode(entry, toolName)); + } + 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 = {}; + for (const [key, value] of Object.entries(node)) { + if (!GEMINI_ALLOWED_KEYWORDS.has(key)) { + continue; // strip an unsupported keyword (additionalProperties, patternProperties, …) + } + if (key === 'format') { + if (typeof value === 'string' && GEMINI_SAFE_FORMATS.has(value)) { + out[key] = value; + } + continue; // drop an unsupported format value + } + if (key === 'properties' && isRecord(value)) { + const properties: Record = {}; + for (const [propName, propSchema] of Object.entries(value)) { + properties[propName] = reshapeNode(propSchema, toolName); + } + out[key] = properties; + continue; + } + if (key === 'items' || key === 'anyOf') { + // `items` is a schema; `anyOf` is an array of schemas — reshapeNode handles both, so + // member schemas are reshaped too (and a `$ref` inside a branch still throws). + out[key] = reshapeNode(value, toolName); + continue; + } + out[key] = value; // type / enum / required / nullable / min|max(Items|Length) / pattern / default — verbatim + } + 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`, or a non-object root). + */ +export function reshapeForGemini(schema: JSONSchema7, toolName: string): Record { + const reshaped = reshapeNode(schema, toolName); + if (!isRecord(reshaped)) { + 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. + */ +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 }; +} From db9206c730fe350611811801d0d121e220596483 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 6 Jun 2026 11:10:18 +0300 Subject: [PATCH 4/9] fix(llm): tighten seam tool-schema validation and gemini reshape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #7 review on the @relavium/llm seam (1.A/1.B/1.E): - types.ts: ToolDef.parameters now rejects arrays/non-objects and the request `signal` is validated structurally against AbortSignalLike, so malformed input is caught at the seam instead of failing later at the provider call or when the signal is observed. - tool-normalizer.ts: reshapeForGemini requires an object root (a primitive-root schema now throws, matching its docstring); a nullable `type: [x, "null"]` union collapses to a scalar type + `nullable: true`, and an inexpressible non-null union throws ToolSchemaError, instead of shipping an invalid Gemini payload. Dropped the bogus `enum` entry from GEMINI_SAFE_FORMATS. - pricing.ts / cost-tracker.ts: replace two unsafe `as` casts with an isCanonicalModelId type guard (Set-of-own-keys, so it is also immune to prototype-chain keys); clarify that cacheWrite is in-code-only with no model_catalog column. - tests: drop unsafe `as ...Wire` casts; add reject/accept cases for array params, signal, primitive-root, and nullable/inexpressible type unions. No vendor SDK type crosses the seam. Refs: ADR-0011 Phase 1 · 1.A/1.B/1.E Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/llm/src/cost-tracker.ts | 14 ++++++---- packages/llm/src/pricing.ts | 17 ++++++++++-- packages/llm/src/tool-normalizer.test.ts | 35 +++++++++++++++++++++--- packages/llm/src/tool-normalizer.ts | 26 ++++++++++++++++-- packages/llm/src/types.test.ts | 12 ++++++++ packages/llm/src/types.ts | 26 +++++++++++++++--- 6 files changed, 112 insertions(+), 18 deletions(-) diff --git a/packages/llm/src/cost-tracker.ts b/packages/llm/src/cost-tracker.ts index 77cef879..930ce7c1 100644 --- a/packages/llm/src/cost-tracker.ts +++ b/packages/llm/src/cost-tracker.ts @@ -1,5 +1,10 @@ import { UnknownModelError } from './errors.js'; -import { KNOWN_MODEL_IDS, MODEL_PRICING, type ModelPricing } from './pricing.js'; +import { + isCanonicalModelId, + KNOWN_MODEL_IDS, + MODEL_PRICING, + type ModelPricing, +} from './pricing.js'; import type { Usage } from './types.js'; /** @@ -10,13 +15,10 @@ import type { Usage } from './types.js'; /** Look up pricing for a canonical model id; throws `UnknownModelError` (never a silent zero). */ export function priceModel(modelId: string): ModelPricing { - const pricing: ModelPricing | undefined = (MODEL_PRICING as Record)[ - modelId - ]; - if (pricing === undefined) { + if (!isCanonicalModelId(modelId)) { throw new UnknownModelError(modelId, KNOWN_MODEL_IDS); } - return pricing; + return MODEL_PRICING[modelId]; } const TOKENS_PER_MTOK = 1_000_000; diff --git a/packages/llm/src/pricing.ts b/packages/llm/src/pricing.ts index d57117f4..dbc7605a 100644 --- a/packages/llm/src/pricing.ts +++ b/packages/llm/src/pricing.ts @@ -5,7 +5,9 @@ import type { ProviderId } from './types.js'; * 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), matching the DB's `*_per_mtok_microcents` columns — no float, ever. + * (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). * @@ -142,5 +144,16 @@ export const MODEL_PRICING = { /** 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 = Object.keys(MODEL_PRICING) as readonly CanonicalModelId[]; +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 index 786b2e61..d7631843 100644 --- a/packages/llm/src/tool-normalizer.test.ts +++ b/packages/llm/src/tool-normalizer.test.ts @@ -8,7 +8,6 @@ import { reshapeForGemini, toWire, } from './tool-normalizer.js'; -import type { AnthropicToolWire, GeminiToolWire, OpenAiToolWire } from './tool-normalizer.js'; import type { ToolDef } from './types.js'; const parameters: JSONSchema7 = { @@ -25,7 +24,7 @@ const toolDef: ToolDef = { name: 'read_file', description: 'Read a file', parame describe('toWire — one canonical ToolDef, three native shapes', () => { it('shapes the Anthropic input_schema form (parameters passed through)', () => { - const wire = toWire(toolDef, 'anthropic') as AnthropicToolWire; + const wire = toWire(toolDef, 'anthropic'); expect(wire).toEqual({ name: 'read_file', description: 'Read a file', @@ -34,7 +33,7 @@ describe('toWire — one canonical ToolDef, three native shapes', () => { }); it('shapes the OpenAI / DeepSeek function form (one adapter, both ids)', () => { - const openai = toWire(toolDef, 'openai') as OpenAiToolWire; + const openai = toWire(toolDef, 'openai'); expect(openai).toEqual({ type: 'function', function: { name: 'read_file', description: 'Read a file', parameters }, @@ -43,7 +42,10 @@ describe('toWire — one canonical ToolDef, three native shapes', () => { }); it('shapes the Gemini functionDeclarations form with the reshaped schema', () => { - const wire = toWire(toolDef, 'gemini') as GeminiToolWire; + 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. @@ -141,6 +143,31 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { } } }); + + 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 = reshaped['properties'] as Record>; + 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); + }); }); describe('GeminiToolCallIds — synthesized, stable tool-call ids', () => { diff --git a/packages/llm/src/tool-normalizer.ts b/packages/llm/src/tool-normalizer.ts index 2ce9874e..d5e0dc9b 100644 --- a/packages/llm/src/tool-normalizer.ts +++ b/packages/llm/src/tool-normalizer.ts @@ -92,7 +92,7 @@ const GEMINI_ALLOWED_KEYWORDS = new Set([ ]); /** `format` values Gemini accepts; an unsupported format is dropped, not sent. */ -const GEMINI_SAFE_FORMATS = new Set(['enum', 'date-time', 'int32', 'int64', 'float', 'double']); +const GEMINI_SAFE_FORMATS = new Set(['date-time', 'int32', 'int64', 'float', 'double']); function reshapeNode(node: unknown, toolName: string): unknown { if (Array.isArray(node)) { @@ -120,6 +120,25 @@ function reshapeNode(node: unknown, toolName: string): unknown { } continue; // drop an unsupported format value } + if (key === 'type' && Array.isArray(value)) { + // JSON-Schema permits `type: [...]`; Gemini wants a scalar type + the separate `nullable` flag. + // Collapse `['string', 'null']` → `type: 'string'` + `nullable: true`; an inexpressible union throws. + const members: unknown[] = value; + const hasNull = members.includes('null'); + const nonNull = members.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)`, + ); + } + out['type'] = nonNull[0]; + if (hasNull) { + out['nullable'] = true; + } + continue; + } if (key === 'properties' && isRecord(value)) { const properties: Record = {}; for (const [propName, propSchema] of Object.entries(value)) { @@ -145,7 +164,7 @@ function reshapeNode(node: unknown, toolName: string): unknown { */ export function reshapeForGemini(schema: JSONSchema7, toolName: string): Record { const reshaped = reshapeNode(schema, toolName); - if (!isRecord(reshaped)) { + if (!isRecord(reshaped) || reshaped['type'] !== 'object') { throw new ToolSchemaError('gemini', toolName, 'tool parameters must be an object schema'); } return reshaped; @@ -159,6 +178,9 @@ 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; diff --git a/packages/llm/src/types.test.ts b/packages/llm/src/types.test.ts index 53ce7f82..4ddcd0ec 100644 --- a/packages/llm/src/types.test.ts +++ b/packages/llm/src/types.test.ts @@ -25,6 +25,17 @@ describe('seam request/message/tool schemas', () => { 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 }); it('accepts a request with tools, toolChoice, and the providerOptions escape hatch', () => { @@ -59,6 +70,7 @@ describe('seam request/message/tool schemas', () => { 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 }); diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index e9c4871c..10536ab2 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -34,9 +34,10 @@ export type ProviderId = LlmProviderId; export const ToolDefSchema = z.object({ name: nonEmptyString, description: z.string().optional(), - parameters: z.custom((value) => typeof value === 'object' && value !== null, { - message: 'parameters must be a JSON-Schema object', - }), + 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; @@ -119,7 +120,24 @@ export const LlmRequestSchema = z.object({ 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. - signal: z.custom().optional(), + // 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; From ef4eee785e60819bf82e64c4e4c3071de3612d43 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 6 Jun 2026 11:10:32 +0300 Subject: [PATCH 5/9] docs(repo): mark Phase 1 Wave 1 (1.A/1.B/1.E) in review under PR #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the status blurbs so they reflect reality: Wave 1 is in progress rather than "next", with the seam workstreams 1.A/1.B/1.E in review under the open PR #7 (2026-06-05). Deliberately "in review", not "landed" — PR #7 is not yet merged to main, and a workstream is only Done once its PR merges. Phase 1 · 1.A/1.B/1.E Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +++--- docs/roadmap/phases/phase-1-engine-and-llm.md | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) 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/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index aa155049..02e1e005 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -1,8 +1,9 @@ # Phase 1 — Engine and LLM > 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 (**1.A** seam ‖ -> **1.L** parser) is next. +> `@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) From 328f32ad3243e9e603b8cb59fb1396b828b379e0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 6 Jun 2026 11:19:49 +0300 Subject: [PATCH 6/9] refactor(llm): cut reshapeNode complexity; clear sonar smells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the SonarQube findings on the seam: - tool-normalizer.ts: split reshapeNode (cognitive complexity 29 → well under the 15 limit) into small per-keyword helpers (reshapeFormat / reshapeTypeArray / reshapeProperties / reshapeKeyword) behind a thin dispatch loop. Behaviour is unchanged — all 42 tests stay green. Also invert the toWire `desc` ternary to drop the negated-condition readability smell. - pricing.ts: drop the redundant `.0` zero-fractions in the usd(...) literals (5.0 → 5, 25.0 → 25, …); the priced values are identical. Refs: ADR-0011 Phase 1 · 1.B/1.E Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/llm/src/pricing.ts | 16 ++--- packages/llm/src/tool-normalizer.ts | 100 ++++++++++++++++------------ 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/packages/llm/src/pricing.ts b/packages/llm/src/pricing.ts index dbc7605a..64adda9b 100644 --- a/packages/llm/src/pricing.ts +++ b/packages/llm/src/pricing.ts @@ -46,8 +46,8 @@ export const MODEL_PRICING = { displayName: 'Claude Opus 4.8', contextWindowTokens: 1_000_000, maxOutputTokens: 128_000, - inputPerMtokMicrocents: usd(5.0), - outputPerMtokMicrocents: usd(25.0), + 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 }, @@ -57,8 +57,8 @@ export const MODEL_PRICING = { displayName: 'Claude Sonnet 4.6', contextWindowTokens: 1_000_000, maxOutputTokens: 64_000, - inputPerMtokMicrocents: usd(3.0), - outputPerMtokMicrocents: usd(15.0), + inputPerMtokMicrocents: usd(3), + outputPerMtokMicrocents: usd(15), cachedInputPerMtokMicrocents: usd(0.3), cacheWritePerMtokMicrocents: usd(3.75), }, @@ -68,8 +68,8 @@ export const MODEL_PRICING = { displayName: 'Claude Haiku 4.5', contextWindowTokens: 200_000, maxOutputTokens: 64_000, - inputPerMtokMicrocents: usd(1.0), - outputPerMtokMicrocents: usd(5.0), + inputPerMtokMicrocents: usd(1), + outputPerMtokMicrocents: usd(5), cachedInputPerMtokMicrocents: usd(0.1), cacheWritePerMtokMicrocents: usd(1.25), }, @@ -82,7 +82,7 @@ export const MODEL_PRICING = { contextWindowTokens: 128_000, maxOutputTokens: 16_384, inputPerMtokMicrocents: usd(2.5), - outputPerMtokMicrocents: usd(10.0), + outputPerMtokMicrocents: usd(10), cachedInputPerMtokMicrocents: usd(1.25), // OpenAI auto-caches; no separate write charge }, 'gpt-4o-mini': { @@ -114,7 +114,7 @@ export const MODEL_PRICING = { contextWindowTokens: 2_097_152, maxOutputTokens: 8_192, inputPerMtokMicrocents: usd(1.25), - outputPerMtokMicrocents: usd(5.0), + outputPerMtokMicrocents: usd(5), cachedInputPerMtokMicrocents: usd(0.3125), }, diff --git a/packages/llm/src/tool-normalizer.ts b/packages/llm/src/tool-normalizer.ts index d5e0dc9b..7d49d545 100644 --- a/packages/llm/src/tool-normalizer.ts +++ b/packages/llm/src/tool-normalizer.ts @@ -42,7 +42,7 @@ 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 } : {}; + const desc = description === undefined ? {} : { description }; switch (providerId) { case 'anthropic': return { name, input_schema: parameters, ...desc }; @@ -94,6 +94,59 @@ const GEMINI_ALLOWED_KEYWORDS = new Set([ /** `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] }; +} + +/** Reshape every member schema under a `properties` map. */ +function reshapeProperties( + value: Record, + toolName: string, +): Record { + const properties: Record = {}; + for (const [propName, propSchema] of Object.entries(value)) { + properties[propName] = reshapeNode(propSchema, toolName); + } + return properties; +} + +/** Reshape one allow-listed keyword into the fragment to merge onto the output node. */ +function reshapeKeyword(key: string, value: unknown, toolName: string): 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) }; + } + 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) }; + } + return { [key]: value }; // enum / required / nullable / min|max(Items|Length) / pattern / default — verbatim +} + function reshapeNode(node: unknown, toolName: string): unknown { if (Array.isArray(node)) { return node.map((entry) => reshapeNode(entry, toolName)); @@ -111,49 +164,10 @@ function reshapeNode(node: unknown, toolName: string): unknown { } const out: Record = {}; for (const [key, value] of Object.entries(node)) { - if (!GEMINI_ALLOWED_KEYWORDS.has(key)) { - continue; // strip an unsupported keyword (additionalProperties, patternProperties, …) - } - if (key === 'format') { - if (typeof value === 'string' && GEMINI_SAFE_FORMATS.has(value)) { - out[key] = value; - } - continue; // drop an unsupported format value - } - if (key === 'type' && Array.isArray(value)) { - // JSON-Schema permits `type: [...]`; Gemini wants a scalar type + the separate `nullable` flag. - // Collapse `['string', 'null']` → `type: 'string'` + `nullable: true`; an inexpressible union throws. - const members: unknown[] = value; - const hasNull = members.includes('null'); - const nonNull = members.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)`, - ); - } - out['type'] = nonNull[0]; - if (hasNull) { - out['nullable'] = true; - } - continue; - } - if (key === 'properties' && isRecord(value)) { - const properties: Record = {}; - for (const [propName, propSchema] of Object.entries(value)) { - properties[propName] = reshapeNode(propSchema, toolName); - } - out[key] = properties; - continue; - } - if (key === 'items' || key === 'anyOf') { - // `items` is a schema; `anyOf` is an array of schemas — reshapeNode handles both, so - // member schemas are reshaped too (and a `$ref` inside a branch still throws). - out[key] = reshapeNode(value, toolName); - continue; + // Strip an unsupported keyword (additionalProperties, patternProperties, …); reshape the rest. + if (GEMINI_ALLOWED_KEYWORDS.has(key)) { + Object.assign(out, reshapeKeyword(key, value, toolName)); } - out[key] = value; // type / enum / required / nullable / min|max(Items|Length) / pattern / default — verbatim } return out; } From 52e2b8ecab4d373a245892a1c03772157ff60325 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 6 Jun 2026 12:10:53 +0300 Subject: [PATCH 7/9] fix(llm): harden ToolNormalizer + close PR #7 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 7-dimension adversarial review of PR #7 surfaced 8 confirmed findings (1 medium, 5 low, 2 info; 3 false-positives filtered). No blockers — all hardening/polish on the 1.E ToolNormalizer plus test coverage. Fixed: - [MED security] reshapeForGemini walked an untrusted tool-parameters JSON-Schema with UNBOUNDED recursion — a deeply-nested schema overflowed the stack with a raw RangeError. Added a MAX_SCHEMA_DEPTH (100) cap that raises a typed ToolSchemaError instead. - [LOW security] a property literally named `__proto__` under `properties` was assigned via bracket syntax, invoking the prototype setter (silently dropping the param + mutating the emitted node's prototype — not global pollution). reshapeProperties now writes members with Object.defineProperty, so `__proto__` survives as an own key. - [LOW correctness] the db9206c tightening over-rejected: a no-argument tool's `parameters: {}` (a typeless object root, valid for Anthropic/OpenAI/DeepSeek) threw for Gemini. reshapeForGemini now defaults a typeless root to `type: 'object'`, throwing only on a non-object root. - [LOW maintainability] a `type: ['string','null']`-derived `nullable: true` could be clobbered by a contradictory verbatim sibling `nullable`; the type-union null is now authoritative. Tests (+ coverage): cases for the depth cap, the `__proto__` own-key, the `{}` default, the nullable precedence, and the primitive-node passthrough branch (`items: true`); deepened the AbortSignalLike validation test (partial-object + wrong-typed `aborted` rejection); added a MODEL_PRICING table-invariant test (keys = KNOWN_MODEL_IDS, every catalog-projection field complete + integer). Suite: 48 green. Review nitpick: replaced the unsafe `reshaped['properties'] as Record<...>` test assertions with a runtime `recordAt` guard (isRecord-based), removing the unsafe `as`. Full gate green; seam fence airtight; no DB drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/llm/src/cost-tracker.test.ts | 25 +++++++- packages/llm/src/tool-normalizer.test.ts | 72 ++++++++++++++++++++++-- packages/llm/src/tool-normalizer.ts | 72 ++++++++++++++++++++---- packages/llm/src/types.test.ts | 13 +++++ 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/packages/llm/src/cost-tracker.test.ts b/packages/llm/src/cost-tracker.test.ts index b7ae646e..a8e77382 100644 --- a/packages/llm/src/cost-tracker.test.ts +++ b/packages/llm/src/cost-tracker.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CostTracker, cost, priceModel } from './cost-tracker.js'; import { UnknownModelError } from './errors.js'; -import { KNOWN_MODEL_IDS } from './pricing.js'; +import { KNOWN_MODEL_IDS, MODEL_PRICING, type ModelPricing } from './pricing.js'; describe('priceModel', () => { it('returns pricing for a known canonical model id', () => { @@ -78,3 +78,26 @@ describe('CostTracker', () => { 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', () => { + expect(Object.keys(MODEL_PRICING).sort()).toEqual([...KNOWN_MODEL_IDS].sort()); + 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/tool-normalizer.test.ts b/packages/llm/src/tool-normalizer.test.ts index d7631843..017af586 100644 --- a/packages/llm/src/tool-normalizer.test.ts +++ b/packages/llm/src/tool-normalizer.test.ts @@ -22,6 +22,25 @@ const parameters: JSONSchema7 = { 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; +} + +/** + * 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`. The JSON.parse boundary cast is the canonical use. + */ +const parseSchema = (json: string): JSONSchema7 => JSON.parse(json) as JSONSchema7; + describe('toWire — one canonical ToolDef, three native shapes', () => { it('shapes the Anthropic input_schema form (parameters passed through)', () => { const wire = toWire(toolDef, 'anthropic'); @@ -75,7 +94,7 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { ); expect(reshaped).not.toHaveProperty('additionalProperties'); expect(reshaped).not.toHaveProperty('patternProperties'); - const props = reshaped['properties'] as Record>; + const props = recordAt(reshaped, 'properties'); expect(props['when']).toEqual({ type: 'string', format: 'date-time' }); expect(props['email']).toEqual({ type: 'string' }); // format dropped }); @@ -95,7 +114,7 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { } satisfies JSONSchema7, 'demo', ); - const value = (reshaped['properties'] as Record>)['value']; + const value = recordAt(reshaped, 'properties')['value']; // The union survives with both branches and their (supported) bound keywords intact. expect(value).toEqual({ anyOf: [ @@ -116,7 +135,7 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { } satisfies JSONSchema7, 'demo', ); - const props = reshaped['properties'] as Record>; + const props = recordAt(reshaped, 'properties'); expect(props['age']).toEqual({ type: 'integer', minimum: 0, maximum: 120 }); expect(props['name']).toEqual({ type: 'string', @@ -156,7 +175,7 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { }, 'demo', ); - const props = reshaped['properties'] as Record>; + const props = recordAt(reshaped, 'properties'); expect(props['opt']).toEqual({ type: 'string', nullable: true }); }); @@ -168,6 +187,51 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { ), ).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.prototype.hasOwnProperty.call(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', () => { diff --git a/packages/llm/src/tool-normalizer.ts b/packages/llm/src/tool-normalizer.ts index 7d49d545..9c2b81f3 100644 --- a/packages/llm/src/tool-normalizer.ts +++ b/packages/llm/src/tool-normalizer.ts @@ -116,20 +116,42 @@ function reshapeTypeArray(value: unknown[], toolName: string): Record, toolName: string, + depth: number, ): Record { const properties: Record = {}; for (const [propName, propSchema] of Object.entries(value)) { - properties[propName] = reshapeNode(propSchema, toolName); + 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): Record { +function reshapeKeyword( + key: string, + value: unknown, + toolName: string, + depth: number, +): Record { if (key === 'format') { return reshapeFormat(value); } @@ -137,19 +159,26 @@ function reshapeKeyword(key: string, value: unknown, toolName: string): Record 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)); + return node.map((entry) => reshapeNode(entry, toolName, depth + 1)); } if (!isRecord(node)) { return node; // a primitive (string type, enum value, etc.) — keep as-is @@ -163,22 +192,41 @@ function reshapeNode(node: unknown, toolName: string): unknown { ); } 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)) { - Object.assign(out, reshapeKeyword(key, value, toolName)); + 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`, or a non-object root). + * `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); - if (!isRecord(reshaped) || reshaped['type'] !== 'object') { + 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; diff --git a/packages/llm/src/types.test.ts b/packages/llm/src/types.test.ts index 4ddcd0ec..ab568399 100644 --- a/packages/llm/src/types.test.ts +++ b/packages/llm/src/types.test.ts @@ -36,6 +36,19 @@ describe('seam request/message/tool schemas', () => { }, }).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', () => { From 8473263d65979199edcda208dedb1ae26b83c843 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 6 Jun 2026 12:17:06 +0300 Subject: [PATCH 8/9] test(llm): clear SonarCloud smells on the new PR #7 test code - cost-tracker.test.ts: give the MODEL_PRICING key-set `.sort()` calls an explicit `localeCompare` comparator (Sonar flags a comparator-less sort as type-dependent). - tool-normalizer.test.ts: use ES2022 `Object.hasOwn(props, '__proto__')` instead of `Object.prototype.hasOwnProperty.call(...)`. Test-only; behavior unchanged. Suite 48 green; gate + fence clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/llm/src/cost-tracker.test.ts | 3 ++- packages/llm/src/tool-normalizer.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/llm/src/cost-tracker.test.ts b/packages/llm/src/cost-tracker.test.ts index a8e77382..6cb0fb95 100644 --- a/packages/llm/src/cost-tracker.test.ts +++ b/packages/llm/src/cost-tracker.test.ts @@ -81,7 +81,8 @@ describe('CostTracker', () => { 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', () => { - expect(Object.keys(MODEL_PRICING).sort()).toEqual([...KNOWN_MODEL_IDS].sort()); + 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); diff --git a/packages/llm/src/tool-normalizer.test.ts b/packages/llm/src/tool-normalizer.test.ts index 017af586..9bf28eae 100644 --- a/packages/llm/src/tool-normalizer.test.ts +++ b/packages/llm/src/tool-normalizer.test.ts @@ -202,7 +202,7 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { ); const props = recordAt(reshaped, 'properties'); expect(Object.getPrototypeOf(props)).toBe(Object.prototype); // own key, not a prototype mutation - expect(Object.prototype.hasOwnProperty.call(props, '__proto__')).toBe(true); + expect(Object.hasOwn(props, '__proto__')).toBe(true); expect(recordAt(props, '__proto__')).toEqual({ type: 'string' }); expect(recordAt(props, 'keep')).toEqual({ type: 'number' }); }); From 2a2cc42cd3042a0f00db47cb6ebf6f70da228d46 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 6 Jun 2026 13:31:39 +0300 Subject: [PATCH 9/9] test(llm): drop the `as JSONSchema7` test casts for runtime guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the PR #7 inline nitpick — no direct `as JSONSchema7` assertion remains in the suite: - parseSchema now parses to `unknown` and narrows with an `isObjectSchema` runtime guard (throws on a non-object), instead of `JSON.parse(json) as JSONSchema7`. - the `$ref` test's literal uses `satisfies JSONSchema7` instead of `as JSONSchema7`. The L84 sort nitpick was already resolved in 8473263 (localeCompare comparator) — no change. Test-only; suite 48 green; gate + fence clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/llm/src/tool-normalizer.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/llm/src/tool-normalizer.test.ts b/packages/llm/src/tool-normalizer.test.ts index 9bf28eae..91b1814a 100644 --- a/packages/llm/src/tool-normalizer.test.ts +++ b/packages/llm/src/tool-normalizer.test.ts @@ -34,12 +34,22 @@ function recordAt(record: Record, key: string): Record 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`. The JSON.parse boundary cast is the canonical use. + * 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 => JSON.parse(json) as JSONSchema7; +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)', () => { @@ -149,7 +159,7 @@ describe('reshapeForGemini — OpenAPI-subset reshape', () => { const schema = { type: 'object', properties: { ref: { $ref: '#/$defs/Thing' } }, - } as JSONSchema7; + } satisfies JSONSchema7; expect(() => reshapeForGemini(schema, 'demo')).toThrowError(ToolSchemaError); try { reshapeForGemini(schema, 'demo');