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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
pnpm --filter @maschina/events build
pnpm --filter @maschina/nats build
pnpm --filter @maschina/jobs build
pnpm --filter @maschina/model build
pnpm --filter @maschina/telemetry build
pnpm --filter @maschina/usage build
pnpm --filter @maschina/billing build
Expand Down Expand Up @@ -127,6 +128,7 @@ jobs:
pnpm --filter @maschina/events build
pnpm --filter @maschina/nats build
pnpm --filter @maschina/jobs build
pnpm --filter @maschina/model build
pnpm --filter @maschina/telemetry build
pnpm --filter @maschina/usage build
pnpm --filter @maschina/billing build
Expand Down Expand Up @@ -231,8 +233,9 @@ jobs:
uv pip install -e packages/agents --system
uv pip install -e packages/risk --system
uv pip install -e "packages/sdk/python[dev]" --system
uv pip install -e "services/runtime[dev]" --system
uv pip install pytest pytest-asyncio pytest-mock --system
- run: pytest packages/runtime/tests packages/agents/tests packages/risk/tests packages/sdk/python/tests -v
- run: pytest packages/runtime/tests packages/agents/tests packages/risk/tests packages/sdk/python/tests services/runtime/tests -v

# ── Gate ──────────────────────────────────────────────────────────────────

Expand Down
5 changes: 0 additions & 5 deletions .lintstagedrc.json

This file was deleted.

23 changes: 23 additions & 0 deletions .lintstagedrc.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { existsSync } from "node:fs";

// Filter helper — lint-staged 15 can temporarily drop newly-tracked files
// from disk during its stash/restore cycle. Filter to only existing files
// before passing to formatters/linters.
const existing = (files) => files.filter(existsSync);

export default {
"*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc}": ["biome check --write --no-errors-on-unmatched"],

"*.py": (files) => {
const ex = existing(files);
if (!ex.length) return [];
const paths = ex.join(" ");
return [`ruff check --fix ${paths}`, `ruff format ${paths}`];
},

"*.rs": (files) => {
const ex = existing(files);
if (!ex.length) return [];
return [`rustfmt --edition 2021 ${ex.join(" ")}`];
},
};
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ Format: [Semantic Versioning](https://semver.org) — `[version] YYYY-MM-DD`

## [Unreleased]

### Added (2026-03-08 — Model routing)
- `packages/model/src/catalog.ts` — TypeScript model catalog: 3 Anthropic cloud models + 3 Ollama local models, per-tier access gates, billing multipliers (Haiku 1x, Sonnet 3x, Opus 15x, Ollama 0x)
- `packages/model/src/index.ts` — Barrel export
- `packages/model/src/catalog.test.ts` — 20 vitest tests covering multipliers, tier access, validation, resolution
- `packages/model/tsconfig.json` + build script — TS package alongside existing Python code
- `packages/validation` — `RunAgentSchema` gains optional `model` field
- `packages/jobs` — `AgentExecuteJob` gains `model` + `systemPrompt` fields; `dispatchAgentRun` updated
- `services/api` — Model access validation at run dispatch; resolves system prompt from `agent.config.systemPrompt`; passes model + system prompt through job queue
- `services/daemon` — `AgentExecuteJob`, `JobToRun` gain `model` + `system_prompt`; `RuntimeRequest` now sends all fields the Python runtime needs (`plan_tier`, `model`, `system_prompt`, `max_tokens`, `timeout_secs`); URL fixed from `/execute` → `/run`
- `services/daemon` — `RunOutput.payload` renamed to `output_payload` to match Python `RunResponse`
- `services/runtime` — Full model routing in `runner.py`: routes by model ID prefix (ollama/* vs Anthropic), applies billing multiplier, lazy-imports Anthropic client per request; drops global Ollama flag
- `services/runtime/tests/test_runner_routing.py` — Unit tests for multiplier + routing helpers (no real LLM calls)
- CI + pytest scripts updated to include `services/runtime` tests

### Fixed (2026-03-08 — Model routing)
- Daemon was calling `/execute` endpoint on Python runtime — correct endpoint is `/run`
- Daemon `RuntimeRequest` was missing `plan_tier`, `model`, `system_prompt`, `timeout_secs` fields that the Python `RunRequest` model requires

### Fixed (2026-03-07 — Session N+1: backend boot + E2E)
- All 31 TS packages now build clean (`pnpm turbo build --filter='./packages/*'`)
- `packages/cache/src/client.ts` — ioredis ESM default import via `(Redis as any)` constructor cast
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
"cargo:run:cli": "cargo run -p maschina-cli",
"cargo:run:code": "cargo run -p maschina-code",

"pytest": "pytest packages/runtime packages/agents packages/ml packages/model packages/risk packages/sdk/python services/worker",
"pytest": "pytest packages/runtime packages/agents packages/ml packages/model packages/risk packages/sdk/python services/worker services/runtime",
"pytest:runtime": "pytest packages/runtime",
"pytest:runtime-service": "pytest services/runtime",
"pytest:agents": "pytest packages/agents",
"pytest:ml": "pytest packages/ml",
"pytest:model": "pytest packages/model",
Expand Down Expand Up @@ -186,7 +187,7 @@
"ci": "pnpm check && pnpm build:packages && pnpm test",
"ci:ts": "pnpm check && pnpm build:packages && turbo test --filter='!@maschina/daemon' --filter='!@maschina/cli' --filter='!@maschina/code' --filter='!@maschina/rust'",
"ci:rust": "pnpm check:rust && pnpm build:rust && pnpm test:rust",
"ci:python": "pytest tests packages/runtime packages/agents packages/ml packages/risk packages/sdk/python services/worker",
"ci:python": "pytest tests packages/runtime packages/agents packages/ml packages/risk packages/sdk/python services/worker services/runtime",
"ci:e2e": "turbo test --filter=@maschina/tests",
"ci:integration": "vitest run --root tests/integration"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/jobs/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export async function dispatchAgentRun(opts: {
agentId: string;
userId: string;
tier: string;
model: string;
systemPrompt: string;
inputPayload: unknown;
timeoutSecs: number;
}): Promise<string> {
Expand Down
2 changes: 2 additions & 0 deletions packages/jobs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface AgentExecuteJob {
agentId: string;
userId: string;
tier: string;
model: string;
systemPrompt: string;
inputPayload: unknown;
timeoutSecs: number;
}
Expand Down
16 changes: 16 additions & 0 deletions packages/model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,27 @@
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"train": "python -m maschina_model.train",
"eval": "python -m maschina_model.eval",
"infer": "python -m maschina_model.infer",
"test": "pytest",
"clean": "rm -rf dist __pycache__ .pytest_cache"
},
"dependencies": {
"@maschina/plans": "workspace:*"
},
"devDependencies": {
"@maschina/tsconfig": "workspace:*",
"typescript": "^5"
}
}
119 changes: 119 additions & 0 deletions packages/model/src/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MODEL,
getAllowedModels,
getModel,
getModelMultiplier,
resolveModel,
validateModelAccess,
} from "./catalog.js";

describe("getModel", () => {
it("returns the model def for a known ID", () => {
const m = getModel("claude-haiku-4-5-20251001");
expect(m).toBeDefined();
expect(m?.provider).toBe("anthropic");
expect(m?.multiplier).toBe(1);
});

it("returns undefined for an unknown ID", () => {
expect(getModel("gpt-99")).toBeUndefined();
});
});

describe("getModelMultiplier", () => {
it("returns 1 for haiku", () => expect(getModelMultiplier("claude-haiku-4-5-20251001")).toBe(1));
it("returns 3 for sonnet", () => expect(getModelMultiplier("claude-sonnet-4-6")).toBe(3));
it("returns 15 for opus", () => expect(getModelMultiplier("claude-opus-4-6")).toBe(15));
it("returns 0 for ollama models", () => expect(getModelMultiplier("ollama/llama3.2")).toBe(0));
it("returns 1 for unknown model (safe default)", () =>
expect(getModelMultiplier("unknown")).toBe(1));
});

describe("getAllowedModels", () => {
it("access tier only gets local ollama models", () => {
const allowed = getAllowedModels("access");
expect(allowed.every((m) => m.isLocal)).toBe(true);
});

it("m1 tier can use haiku and ollama", () => {
const ids = getAllowedModels("m1").map((m) => m.id);
expect(ids).toContain("claude-haiku-4-5-20251001");
expect(ids).toContain("ollama/llama3.2");
expect(ids).not.toContain("claude-sonnet-4-6");
expect(ids).not.toContain("claude-opus-4-6");
});

it("m5 tier can use haiku and sonnet but not opus", () => {
const ids = getAllowedModels("m5").map((m) => m.id);
expect(ids).toContain("claude-haiku-4-5-20251001");
expect(ids).toContain("claude-sonnet-4-6");
expect(ids).not.toContain("claude-opus-4-6");
});

it("m10 tier can use all models", () => {
const ids = getAllowedModels("m10").map((m) => m.id);
expect(ids).toContain("claude-haiku-4-5-20251001");
expect(ids).toContain("claude-sonnet-4-6");
expect(ids).toContain("claude-opus-4-6");
});

it("internal tier can use all models", () => {
const ids = getAllowedModels("internal").map((m) => m.id);
expect(ids).toContain("claude-opus-4-6");
});
});

describe("validateModelAccess", () => {
it("allows access tier to use ollama", () => {
const result = validateModelAccess("access", "ollama/llama3.2");
expect(result.allowed).toBe(true);
});

it("denies access tier from using haiku", () => {
const result = validateModelAccess("access", "claude-haiku-4-5-20251001");
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/m1/);
});

it("denies m1 tier from using sonnet", () => {
const result = validateModelAccess("m1", "claude-sonnet-4-6");
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/m5/);
});

it("denies m5 tier from using opus", () => {
const result = validateModelAccess("m5", "claude-opus-4-6");
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/m10/);
});

it("allows m10 tier to use opus", () => {
const result = validateModelAccess("m10", "claude-opus-4-6");
expect(result.allowed).toBe(true);
});

it("denies unknown model with clear error", () => {
const result = validateModelAccess("enterprise", "gpt-99");
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/Unknown model/);
});
});

describe("resolveModel", () => {
it("returns the requested model if allowed", () => {
expect(resolveModel("m5", "claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5-20251001");
});

it("falls back to tier default if requested model is denied", () => {
// m1 requesting opus → should fall back to m1 default
expect(resolveModel("m1", "claude-opus-4-6")).toBe(DEFAULT_MODEL.m1);
});

it("returns tier default when no model is requested", () => {
expect(resolveModel("access")).toBe("ollama/llama3.2");
expect(resolveModel("m1")).toBe("claude-haiku-4-5-20251001");
expect(resolveModel("m5")).toBe("claude-sonnet-4-6");
expect(resolveModel("m10")).toBe("claude-opus-4-6");
});
});
Loading
Loading