Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions docs/reference/shared-core/llm-provider-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 24 additions & 17 deletions docs/roadmap/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/roadmap/deferred-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions docs/roadmap/phases/phase-1-engine-and-llm.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Phase 1 — Engine and LLM

> Status: Not started — the critical path (Product Phase 1). Blocked on Phase 0 exit.
> Status: In progress — the critical path (Product Phase 1). Wave 0 (**1.L.0**, the
> `@relavium/shared` reconciliation) landed in **PR #6** (2026-06-05); Wave 1 is **in progress** —
> the seam workstreams **1.A** (types), **1.B** (CostTracker), and **1.E** (ToolNormalizer) are in
> review under **PR #7** (2026-06-05), alongside the **1.L** parser.

- **Related**: [../README.md](../README.md), [phase-0-foundations.md](phase-0-foundations.md), [phase-2-cli.md](phase-2-cli.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/multi-llm-providers.md](../../architecture/multi-llm-providers.md), [../../reference/shared-core/llm-provider-seam.md](../../reference/shared-core/llm-provider-seam.md), [../../reference/shared-core/node-types.md](../../reference/shared-core/node-types.md), [../../reference/shared-core/built-in-tools.md](../../reference/shared-core/built-in-tools.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../standards/testing.md](../../standards/testing.md), [../../standards/error-handling.md](../../standards/error-handling.md), [../../decisions/0011-internal-llm-abstraction.md](../../decisions/0011-internal-llm-abstraction.md)

Expand Down Expand Up @@ -382,7 +385,7 @@ budgets. Adapters stay dumb; this owns the policy.
to the next provider and the run succeeds; a fatal error stops the chain; and
per-attempt cost is summed across a failover.

### 1.L.0 — Reconcile `@relavium/shared` to the 2026-06-05 contract — *critical path, do first*
### 1.L.0 — Reconcile `@relavium/shared` to the 2026-06-05 contract — ✅ **Done (PR #6)** · *critical path, do first*

`@relavium/shared` was frozen in Phase 0 (2026-06-04); the agent-first + hardening ADRs
([0026](../../decisions/0026-session-export-to-workflow.md)/[0027](../../decisions/0027-expression-sandbox.md)/[0028](../../decisions/0028-workflow-resource-governance.md)/[0029](../../decisions/0029-tool-policy-hardening.md))
Expand Down
26 changes: 18 additions & 8 deletions packages/llm/README.md
Original file line number Diff line number Diff line change
@@ -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)).
35 changes: 35 additions & 0 deletions packages/llm/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
104 changes: 104 additions & 0 deletions packages/llm/src/cost-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';

import { CostTracker, cost, priceModel } from './cost-tracker.js';
import { UnknownModelError } from './errors.js';
import { KNOWN_MODEL_IDS, MODEL_PRICING, type ModelPricing } from './pricing.js';

describe('priceModel', () => {
it('returns pricing for a known canonical model id', () => {
const p = priceModel('claude-opus-4-8');
expect(p.provider).toBe('anthropic');
expect(p.inputPerMtokMicrocents).toBe(500_000_000); // $5/MTok → 5e8 micro-cents
});

it('throws a typed UnknownModelError (never a silent zero)', () => {
expect(() => priceModel('gpt-9-ultra')).toThrowError(UnknownModelError);
try {
priceModel('gpt-9-ultra');
} catch (err) {
expect(err).toBeInstanceOf(UnknownModelError);
if (err instanceof UnknownModelError) {
expect(err.code).toBe('unknown_model');
expect(err.modelId).toBe('gpt-9-ultra');
expect(err.knownModels).toEqual(KNOWN_MODEL_IDS);
}
}
});
});

describe('cost', () => {
it('prices input + output in integer micro-cents (Opus 4.8)', () => {
// 1000 in @ $5/MTok = 500_000µ¢; 500 out @ $25/MTok = 1_250_000µ¢
expect(cost('claude-opus-4-8', { inputTokens: 1000, outputTokens: 500 })).toBe(1_750_000);
});

it('prices each token class once, including cache read + write (Opus 4.8)', () => {
expect(
cost('claude-opus-4-8', {
inputTokens: 1000, // 500_000
outputTokens: 500, // 1_250_000
cacheReadTokens: 2000, // 2000 @ $0.50/MTok = 100_000
cacheWriteTokens: 4000, // 4000 @ $6.25/MTok = 2_500_000
}),
).toBe(4_350_000);
});

it('prices Sonnet 4.6 and DeepSeek from their own rows', () => {
expect(cost('claude-sonnet-4-6', { inputTokens: 1000, outputTokens: 500 })).toBe(1_050_000);
// deepseek-chat: 1000 in @ $0.27/MTok = 27_000µ¢; 500 out @ $1.10/MTok = 55_000µ¢
expect(cost('deepseek-chat', { inputTokens: 1000, outputTokens: 500 })).toBe(82_000);
});

it('ignores cache-write tokens for a provider with no cache-write price (gpt-4o)', () => {
// gpt-4o has no cacheWritePerMtokMicrocents → the 1000 cache-write tokens cost 0.
expect(cost('gpt-4o', { inputTokens: 1000, outputTokens: 0, cacheWriteTokens: 1000 })).toBe(
250_000,
); // 1000 in @ $2.50/MTok = 250_000
});

it('rounds the per-class micro-cent figure (half-up)', () => {
// gpt-4o-mini cached input $0.075/MTok = 7_500_000µ¢/MTok → 1 token = 7.5 → rounds to 8.
expect(cost('gpt-4o-mini', { inputTokens: 0, outputTokens: 0, cacheReadTokens: 1 })).toBe(8);
});
});

describe('CostTracker', () => {
it('accumulates per-attempt cost across a failover', () => {
const tracker = new CostTracker();
const a = tracker.record('claude-opus-4-8', { inputTokens: 1000, outputTokens: 500 });
expect(a).toEqual({
inputTokens: 1000,
outputTokens: 500,
costMicrocents: 1_750_000,
cumulativeCostMicrocents: 1_750_000,
});
const b = tracker.record('gpt-4o', { inputTokens: 1000, outputTokens: 0 });
expect(b.costMicrocents).toBe(250_000);
expect(b.cumulativeCostMicrocents).toBe(2_000_000); // 1_750_000 + 250_000
expect(tracker.cumulativeCostMicrocents).toBe(2_000_000);
});
});

describe('MODEL_PRICING table invariants (the values seeded into model_catalog)', () => {
it('keys match KNOWN_MODEL_IDS and every catalog-projection field is complete + integer', () => {
const byLocale = (a: string, b: string): number => a.localeCompare(b);
expect(Object.keys(MODEL_PRICING).sort(byLocale)).toEqual([...KNOWN_MODEL_IDS].sort(byLocale));
const rows: Array<[string, ModelPricing]> = Object.entries(MODEL_PRICING);
for (const [id, row] of rows) {
expect(row.nativeId.length, id).toBeGreaterThan(0);
expect(row.displayName.length, id).toBeGreaterThan(0);
expect(row.contextWindowTokens, id).toBeGreaterThan(0);
expect(row.maxOutputTokens, id).toBeGreaterThan(0);
// Prices are integer micro-cents per MTok — never a float, and never negative.
for (const price of [
row.inputPerMtokMicrocents,
row.outputPerMtokMicrocents,
row.cachedInputPerMtokMicrocents,
row.cacheWritePerMtokMicrocents ?? 0,
]) {
expect(Number.isInteger(price), id).toBe(true);
expect(price, id).toBeGreaterThanOrEqual(0);
}
}
});
});
81 changes: 81 additions & 0 deletions packages/llm/src/cost-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { UnknownModelError } from './errors.js';
import {
isCanonicalModelId,
KNOWN_MODEL_IDS,
MODEL_PRICING,
type ModelPricing,
} from './pricing.js';
import type { Usage } from './types.js';

/**
* Cost tracking Relavium owns, keyed on the canonical model id — never read from a provider
* field (1.B). All figures are integer **micro-cents** (1 micro-cent = 1e-8 USD), consistent with
* the seam's `Usage.costMicrocents` and the `cost:updated` event.
*/

/** Look up pricing for a canonical model id; throws `UnknownModelError` (never a silent zero). */
export function priceModel(modelId: string): ModelPricing {
if (!isCanonicalModelId(modelId)) {
throw new UnknownModelError(modelId, KNOWN_MODEL_IDS);
}
return MODEL_PRICING[modelId];
}
Comment on lines +17 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using direct property lookup MODEL_PRICING[modelId] can lead to unexpected behavior or bypasses if modelId matches built-in Object.prototype properties (such as 'toString', 'valueOf', or 'constructor'). Since modelId is a dynamic string (potentially coming from user-authored YAML or external inputs), it is safer to verify ownership using Object.prototype.hasOwnProperty.call before accessing the pricing record.

export function priceModel(modelId: string): ModelPricing {
  if (!Object.prototype.hasOwnProperty.call(MODEL_PRICING, modelId)) {
    throw new UnknownModelError(modelId, KNOWN_MODEL_IDS);
  }
  return (MODEL_PRICING as Record<string, ModelPricing>)[modelId]!;
}


const TOKENS_PER_MTOK = 1_000_000;

/**
* The integer micro-cent cost of one usage record. Each token class is `tokens ×
* pricePerMtokMicrocents / 1e6`, rounded once (the only float in the path; everything downstream
* sums integers).
*
* **Convention:** `inputTokens` is the count billed at the full input rate — cache-read and
* cache-write tokens are **separate** counts, not included in `inputTokens`. Adapters normalize to
* this (Anthropic's `input_tokens` is already net; the OpenAI-compatible adapter subtracts the
* cached subset). So each token class is billed exactly once.
*/
export function cost(modelId: string, usage: Usage): number {
const p = priceModel(modelId);
const cacheReadTokens = usage.cacheReadTokens ?? 0;
const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
const perClass = (tokens: number, ratePerMtok: number): number =>
Math.round((tokens * ratePerMtok) / TOKENS_PER_MTOK);
return (
perClass(usage.inputTokens, p.inputPerMtokMicrocents) +
perClass(usage.outputTokens, p.outputPerMtokMicrocents) +
perClass(cacheReadTokens, p.cachedInputPerMtokMicrocents) +
perClass(cacheWriteTokens, p.cacheWritePerMtokMicrocents ?? 0)
);
}

/** The cost figures for one `cost:updated` event (the engine adds `nodeId` / `model` / `attemptNumber`). */
export interface CostUpdate {
readonly inputTokens: number;
readonly outputTokens: number;
readonly costMicrocents: number;
readonly cumulativeCostMicrocents: number;
}

/**
* Accumulates per-attempt cost across a node/session. Call `record` once per LLM attempt (each
* fallback attempt has its own `Usage`), so the running total stays accurate across a failover.
*/
export class CostTracker {
#cumulativeMicrocents = 0;

/** Price one attempt's usage and fold it into the running total. */
record(modelId: string, usage: Usage): CostUpdate {
const costMicrocents = cost(modelId, usage);
this.#cumulativeMicrocents += costMicrocents;
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
costMicrocents,
cumulativeCostMicrocents: this.#cumulativeMicrocents,
};
}

/** The running total in integer micro-cents. */
get cumulativeCostMicrocents(): number {
return this.#cumulativeMicrocents;
}
}
Loading
Loading