From 1b05a9fd49d4c2ad88f2a8b3ed1832de2a1ff4d6 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 11:15:18 +0100 Subject: [PATCH 01/11] feat(harness): normalised types + AnthropicProvider Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent/src/providers/anthropic.ts | 110 ++++++++++++++++++++++ packages/agent/src/providers/index.ts | 13 +++ packages/agent/src/providers/types.ts | 37 ++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/agent/src/providers/anthropic.ts create mode 100644 packages/agent/src/providers/index.ts create mode 100644 packages/agent/src/providers/types.ts diff --git a/packages/agent/src/providers/anthropic.ts b/packages/agent/src/providers/anthropic.ts new file mode 100644 index 0000000..52af713 --- /dev/null +++ b/packages/agent/src/providers/anthropic.ts @@ -0,0 +1,110 @@ +// packages/agent/src/providers/anthropic.ts +// Translates normalised types to/from the Anthropic Messages API. + +import Anthropic from "@anthropic-ai/sdk"; +import type { + CompletionProvider, + ChatParams, + ChatResponse, + ChatMessage, + ContentBlock, + ToolDefinition, +} from "./types.js"; + +export class AnthropicProvider implements CompletionProvider { + private client: Anthropic; + + constructor(apiKey?: string) { + this.client = new Anthropic({ apiKey }); + } + + async chat(params: ChatParams): Promise { + const response = await this.client.messages.create({ + model: params.model, + max_tokens: params.maxTokens, + temperature: params.temperature, + system: params.system, + tools: params.tools.map(toAnthropicTool), + messages: params.messages.map(toAnthropicMessage), + }); + + return { + content: response.content.map(fromAnthropicBlock), + stopReason: + response.stop_reason === "tool_use" + ? "tool_use" + : response.stop_reason === "max_tokens" + ? "max_tokens" + : "end_turn", + usage: { + input: response.usage.input_tokens, + output: response.usage.output_tokens, + }, + }; + } +} + +function toAnthropicTool(tool: ToolDefinition): Anthropic.Tool { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema as Anthropic.Tool["input_schema"], + }; +} + +function toAnthropicMessage(msg: ChatMessage): Anthropic.MessageParam { + if (msg.role === "tool_result") { + return { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: msg.toolUseId, + content: msg.content, + }, + ], + }; + } + if (msg.role === "assistant") { + return { + role: "assistant", + content: msg.content.map(toAnthropicContentBlock), + }; + } + // user message — content is string or ContentBlock[] + if (typeof msg.content === "string") { + return { role: "user", content: msg.content }; + } + return { + role: "user", + content: msg.content.map(toAnthropicContentBlock), + }; +} + +function toAnthropicContentBlock( + block: ContentBlock, +): Anthropic.ContentBlockParam { + if (block.type === "tool_use") { + return { + type: "tool_use", + id: block.id, + name: block.name, + input: block.input, + }; + } + return { type: "text", text: block.text }; +} + +function fromAnthropicBlock(block: Anthropic.ContentBlock): ContentBlock { + if (block.type === "tool_use") { + return { + type: "tool_use", + id: block.id, + name: block.name, + input: block.input as Record, + }; + } + // Anthropic response blocks are either "text" or "tool_use". + // Cast to TextBlock to access the text field safely. + return { type: "text", text: (block as Anthropic.TextBlock).text }; +} diff --git a/packages/agent/src/providers/index.ts b/packages/agent/src/providers/index.ts new file mode 100644 index 0000000..65b81a9 --- /dev/null +++ b/packages/agent/src/providers/index.ts @@ -0,0 +1,13 @@ +// packages/agent/src/providers/index.ts +// Barrel export for the model harness. + +export type { + CompletionProvider, + ChatParams, + ChatResponse, + ChatMessage, + ContentBlock, + ToolDefinition, +} from "./types.js"; + +export { AnthropicProvider } from "./anthropic.js"; diff --git a/packages/agent/src/providers/types.ts b/packages/agent/src/providers/types.ts new file mode 100644 index 0000000..fec9106 --- /dev/null +++ b/packages/agent/src/providers/types.ts @@ -0,0 +1,37 @@ +// packages/agent/src/providers/types.ts +// Normalised types for the model harness — provider-agnostic interface. +// No dependency on any LLM SDK. + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; // JSON Schema +} + +export interface ChatParams { + model: string; + system: string; + tools: ToolDefinition[]; + messages: ChatMessage[]; + temperature: number; + maxTokens: number; +} + +export type ChatMessage = + | { role: "user"; content: string | ContentBlock[] } + | { role: "assistant"; content: ContentBlock[] } + | { role: "tool_result"; toolUseId: string; content: string }; + +export type ContentBlock = + | { type: "text"; text: string } + | { type: "tool_use"; id: string; name: string; input: Record }; + +export interface ChatResponse { + content: ContentBlock[]; + stopReason: "end_turn" | "tool_use" | "max_tokens"; + usage: { input: number; output: number }; +} + +export interface CompletionProvider { + chat(params: ChatParams): Promise; +} From 011905c46773fcc0add8ef0738c414ec2f86521c Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 11:19:39 +0100 Subject: [PATCH 02/11] feat(harness): refactor agent to CompletionProvider + MockProvider Remove direct @anthropic-ai/sdk imports from agent.ts, conversation.ts, and reasoning-loop.ts. All LLM calls now go through the CompletionProvider interface. Add MockProvider for test use. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent/src/agent.ts | 61 +++++++++++++++------------ packages/agent/src/conversation.ts | 10 +++-- packages/agent/src/providers/index.ts | 1 + packages/agent/src/providers/mock.ts | 22 ++++++++++ packages/agent/src/reasoning-loop.ts | 48 ++++++++++++--------- 5 files changed, 93 insertions(+), 49 deletions(-) create mode 100644 packages/agent/src/providers/mock.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 20d0af5..b0df04c 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,7 +1,14 @@ -import Anthropic from "@anthropic-ai/sdk"; import type { createContainer } from "@lexius/core"; import { handleToolCall } from "./tools.js"; import { logger } from "./logger.js"; +import { AnthropicProvider } from "./providers/anthropic.js"; +import type { + CompletionProvider, + ToolDefinition, + ChatMessage, + ChatResponse, + ContentBlock, +} from "./providers/types.js"; type Container = ReturnType; @@ -43,13 +50,13 @@ export interface AgentConfig { riskLevels: string[]; } -function buildTools(config: AgentConfig): Anthropic.Tool[] { +function buildTools(config: AgentConfig): ToolDefinition[] { return [ { name: "classify_system", description: "Classify an AI system under a legislation's risk framework. Provide signals for structured classification or a description for keyword/semantic matching.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -83,7 +90,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "get_obligations", description: "Get compliance obligations filtered by legislation, role, and/or risk level. Results include provenance tier.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -109,7 +116,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "calculate_penalty", description: "Calculate potential penalties for a specific violation type. Returns AUTHORITATIVE fine amounts extracted from verbatim regulation text.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -138,7 +145,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "search_knowledge", description: "Semantic search across the compliance knowledge base. Results include provenance tier for each match.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -167,7 +174,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "get_article", description: "Retrieve a specific article by number. Returns verbatim regulation text with AUTHORITATIVE provenance when fetched from CELLAR.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -187,7 +194,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "get_deadlines", description: "Get compliance deadlines for a legislation, optionally filtering to only upcoming ones.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -207,7 +214,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "answer_question", description: "Answer a question using the FAQ knowledge base with semantic matching. FAQ answers are CURATED, not AUTHORITATIVE — flag this to the user.", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -227,7 +234,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { name: "run_assessment", description: "Run a structured assessment (e.g. Article 6 exception check, GPAI systemic risk assessment).", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { @@ -250,7 +257,7 @@ function buildTools(config: AgentConfig): Anthropic.Tool[] { { name: "list_legislations", description: "List all available legislations in the database.", - input_schema: { + inputSchema: { type: "object" as const, properties: {}, required: [], @@ -291,8 +298,12 @@ export async function loadAgentConfig(container: Container): Promise { + messages: ChatMessage[], + ): Promise { const model = process.env.ANTHROPIC_MODEL_REASONING || process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6"; - logger.debug({ model }, "Calling Anthropic API"); + logger.debug({ model }, "Calling LLM via provider"); - const response = await client.messages.create({ + const response = await llm.chat({ model, - max_tokens: 4096, + maxTokens: 4096, temperature: 0, system: SYSTEM_PROMPT, tools, @@ -322,28 +333,26 @@ export function createAgent(container: Container, config?: AgentConfig) { }); const toolUseBlocks = response.content.filter( - (block): block is Anthropic.ContentBlockParam & { type: "tool_use" } => + (block): block is ContentBlock & { type: "tool_use" } => block.type === "tool_use", ); - if (toolUseBlocks.length > 0 && response.stop_reason === "tool_use") { + if (toolUseBlocks.length > 0 && response.stopReason === "tool_use") { messages.push({ role: "assistant", content: response.content }); - const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const toolUse of toolUseBlocks) { const result = await handleToolCall( container, toolUse.name, - (toolUse.input as Record) ?? {}, + toolUse.input ?? {}, ); - toolResults.push({ - type: "tool_result", - tool_use_id: toolUse.id, + messages.push({ + role: "tool_result", + toolUseId: toolUse.id, content: result, }); } - messages.push({ role: "user", content: toolResults }); return chat(messages); } diff --git a/packages/agent/src/conversation.ts b/packages/agent/src/conversation.ts index 96b8872..c4e228e 100644 --- a/packages/agent/src/conversation.ts +++ b/packages/agent/src/conversation.ts @@ -1,9 +1,9 @@ import * as readline from "node:readline"; -import type Anthropic from "@anthropic-ai/sdk"; import type { createContainer } from "@lexius/core"; import { createAgent, type AgentConfig } from "./agent.js"; import { ReasoningLoop } from "./reasoning-loop.js"; import { logger } from "./logger.js"; +import type { ChatMessage } from "./providers/types.js"; type Container = ReturnType; @@ -15,7 +15,7 @@ export async function startConversation( logger.info("Conversation started"); const agent = createAgent(container, config); - const messages: Anthropic.MessageParam[] = []; + const messages: ChatMessage[] = []; const rl = readline.createInterface({ input: process.stdin, @@ -85,9 +85,11 @@ export async function startConversation( // Extract text from response const textBlocks = response.content.filter( - (block): block is Anthropic.TextBlock => block.type === "text", + (block) => block.type === "text", ); - const assistantText = textBlocks.map((b) => b.text).join("\n"); + const assistantText = textBlocks + .map((b) => (b.type === "text" ? b.text : "")) + .join("\n"); console.log(); console.log(`Assistant: ${assistantText}`); diff --git a/packages/agent/src/providers/index.ts b/packages/agent/src/providers/index.ts index 65b81a9..ee07936 100644 --- a/packages/agent/src/providers/index.ts +++ b/packages/agent/src/providers/index.ts @@ -11,3 +11,4 @@ export type { } from "./types.js"; export { AnthropicProvider } from "./anthropic.js"; +export { MockProvider } from "./mock.js"; diff --git a/packages/agent/src/providers/mock.ts b/packages/agent/src/providers/mock.ts new file mode 100644 index 0000000..ed0d5a7 --- /dev/null +++ b/packages/agent/src/providers/mock.ts @@ -0,0 +1,22 @@ +// packages/agent/src/providers/mock.ts +// Records every call for test assertions. Returns canned responses in order. + +import type { CompletionProvider, ChatParams, ChatResponse } from "./types.js"; + +export class MockProvider implements CompletionProvider { + private responses: ChatResponse[]; + public calls: ChatParams[] = []; + + constructor(responses: ChatResponse[] = []) { + this.responses = [...responses]; + } + + async chat(params: ChatParams): Promise { + this.calls.push(params); + return this.responses.shift() ?? { + content: [{ type: "text", text: "mock response" }], + stopReason: "end_turn", + usage: { input: 0, output: 0 }, + }; + } +} diff --git a/packages/agent/src/reasoning-loop.ts b/packages/agent/src/reasoning-loop.ts index 1e1ed7e..353328b 100644 --- a/packages/agent/src/reasoning-loop.ts +++ b/packages/agent/src/reasoning-loop.ts @@ -1,9 +1,15 @@ -import Anthropic from "@anthropic-ai/sdk"; import type { createContainer } from "@lexius/core"; import type { AuditInput, ComplianceReport, EnhancedComplianceReport } from "@lexius/core"; import { logger } from "./logger.js"; import { AnthropicEnhancementService } from "./anthropic-enhancement-service.js"; import { handleToolCall } from "./tools.js"; +import { AnthropicProvider } from "./providers/anthropic.js"; +import type { + CompletionProvider, + ToolDefinition, + ChatMessage, + ChatResponse, +} from "./providers/types.js"; import * as readline from "node:readline"; type Container = ReturnType; @@ -24,15 +30,15 @@ interface AssessmentContext { const MAX_SIGNAL_QUESTIONS = 5; export class ReasoningLoop { - private readonly client: Anthropic; + private readonly llm: CompletionProvider; private readonly container: Container; private readonly enhancementService: AnthropicEnhancementService; private state: State = "INTAKE"; private context: AssessmentContext; - constructor(container: Container) { + constructor(container: Container, provider?: CompletionProvider) { this.container = container; - this.client = new Anthropic(); + this.llm = provider ?? new AnthropicProvider(); this.enhancementService = new AnthropicEnhancementService(); this.context = { systemDescription: "", @@ -207,11 +213,11 @@ export class ReasoningLoop { // PRESENTATION: offer follow-ups console.log("\nYou can ask follow-up questions or type 'quit' to exit.\n"); - const followUpTools: Anthropic.Tool[] = [ + const followUpTools: ToolDefinition[] = [ { name: "search_knowledge", description: "Search regulation text", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { type: "string" }, @@ -225,7 +231,7 @@ export class ReasoningLoop { { name: "get_article", description: "Get a specific article", - input_schema: { + inputSchema: { type: "object" as const, properties: { legislationId: { type: "string" }, @@ -242,34 +248,38 @@ export class ReasoningLoop { const model = process.env.ANTHROPIC_MODEL_REASONING || process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6"; - const response = await this.client.messages.create({ + const response = await this.llm.chat({ model, - max_tokens: 2048, + maxTokens: 2048, + temperature: 0, system: "You are a compliance consultant. Answer based on regulation data using the available tools. Cite article numbers.", tools: followUpTools, messages: [{ role: "user", content: followUp }], }); // Handle tool call loop - let messages: Anthropic.MessageParam[] = [{ role: "user", content: followUp }]; - let currentResponse = response; + let messages: ChatMessage[] = [{ role: "user", content: followUp }]; + let currentResponse: ChatResponse = response; - while (currentResponse.stop_reason === "tool_use") { + while (currentResponse.stopReason === "tool_use") { const assistantContent = currentResponse.content; messages.push({ role: "assistant", content: assistantContent }); - const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of assistantContent) { if (block.type === "tool_use") { const result = await handleToolCall(this.container, block.name, block.input as Record); - toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result }); + messages.push({ + role: "tool_result", + toolUseId: block.id, + content: result, + }); } } - messages.push({ role: "user", content: toolResults }); - currentResponse = await this.client.messages.create({ + currentResponse = await this.llm.chat({ model, - max_tokens: 2048, + maxTokens: 2048, + temperature: 0, system: "You are a compliance consultant. Answer based on regulation data using the available tools. Cite article numbers.", tools: followUpTools, messages, @@ -277,8 +287,8 @@ export class ReasoningLoop { } const text = currentResponse.content - .filter((b): b is Anthropic.TextBlock => b.type === "text") - .map((b) => b.text) + .filter((b) => b.type === "text") + .map((b) => (b.type === "text" ? b.text : "")) .join(""); if (text) console.log(`\n${text}\n`); } From 55e3813023a9a63bdc90c22be5a090bd1a6eb9f5 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 11:22:02 +0100 Subject: [PATCH 03/11] feat(harness): OpenAI + Ollama providers + factory - OpenAIProvider: translates normalised types to OpenAI function-calling format. Handles tool_calls response parsing, parallel tool calls, usage tracking. - OllamaProvider: 3-line subclass of OpenAI (API-compatible), defaults to localhost:11434/v1. - createProvider() factory: reads LEXIUS_MODEL_PROVIDER env var (anthropic|openai|ollama|mock), returns the right provider. Dynamic require so unused SDKs don't error. - getDefaultModel(): per-provider defaults with LEXIUS_MODEL override. - Added openai as agent dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent/package.json | 7 +- packages/agent/src/providers/index.ts | 48 +++++++++- packages/agent/src/providers/ollama.ts | 10 ++ packages/agent/src/providers/openai.ts | 122 +++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 packages/agent/src/providers/ollama.ts create mode 100644 packages/agent/src/providers/openai.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index a54163a..4382c22 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -27,13 +27,14 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", - "pino": "^9.6.0", - "pino-pretty": "^13.0.0", "@lexius/core": "workspace:*", "@lexius/db": "workspace:*", "@lexius/infra": "workspace:*", + "@lexius/logger": "workspace:*", "drizzle-orm": "^0.35.0", - "@lexius/logger": "workspace:*" + "openai": "^4.70.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0" }, "devDependencies": { "typescript": "^5.8.3" diff --git a/packages/agent/src/providers/index.ts b/packages/agent/src/providers/index.ts index ee07936..0e87255 100644 --- a/packages/agent/src/providers/index.ts +++ b/packages/agent/src/providers/index.ts @@ -1,6 +1,3 @@ -// packages/agent/src/providers/index.ts -// Barrel export for the model harness. - export type { CompletionProvider, ChatParams, @@ -11,4 +8,49 @@ export type { } from "./types.js"; export { AnthropicProvider } from "./anthropic.js"; +export { OpenAIProvider } from "./openai.js"; +export { OllamaProvider } from "./ollama.js"; export { MockProvider } from "./mock.js"; + +import type { CompletionProvider } from "./types.js"; + +export function createProvider(override?: string): CompletionProvider { + const provider = override || process.env.LEXIUS_MODEL_PROVIDER || "anthropic"; + + switch (provider) { + case "anthropic": { + const { AnthropicProvider } = require("./anthropic.js"); + return new AnthropicProvider(process.env.ANTHROPIC_API_KEY); + } + case "openai": { + const { OpenAIProvider } = require("./openai.js"); + return new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }); + } + case "ollama": { + const { OllamaProvider } = require("./ollama.js"); + return new OllamaProvider(); + } + case "mock": { + const { MockProvider } = require("./mock.js"); + return new MockProvider(); + } + default: + throw new Error( + `Unknown model provider: ${provider}. Valid: anthropic, openai, ollama, mock`, + ); + } +} + +export function getDefaultModel(provider?: string): string { + const p = provider || process.env.LEXIUS_MODEL_PROVIDER || "anthropic"; + const override = process.env.LEXIUS_MODEL; + if (override) return override; + + switch (p) { + case "anthropic": return process.env.ANTHROPIC_MODEL_REASONING || process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6"; + case "openai": return "gpt-4o"; + case "ollama": return "llama3"; + case "mock": return "mock"; + default: return "claude-sonnet-4-6"; + } +} diff --git a/packages/agent/src/providers/ollama.ts b/packages/agent/src/providers/ollama.ts new file mode 100644 index 0000000..7262eb1 --- /dev/null +++ b/packages/agent/src/providers/ollama.ts @@ -0,0 +1,10 @@ +import { OpenAIProvider } from "./openai.js"; + +export class OllamaProvider extends OpenAIProvider { + constructor() { + super({ + apiKey: "ollama", + baseURL: process.env.OLLAMA_URL || "http://localhost:11434/v1", + }); + } +} diff --git a/packages/agent/src/providers/openai.ts b/packages/agent/src/providers/openai.ts new file mode 100644 index 0000000..7d8888a --- /dev/null +++ b/packages/agent/src/providers/openai.ts @@ -0,0 +1,122 @@ +import OpenAI from "openai"; +import type { + CompletionProvider, + ChatParams, + ChatResponse, + ChatMessage, + ContentBlock, + ToolDefinition, +} from "./types.js"; + +export class OpenAIProvider implements CompletionProvider { + protected client: OpenAI; + + constructor(config?: { apiKey?: string; baseURL?: string }) { + this.client = new OpenAI({ + apiKey: config?.apiKey, + baseURL: config?.baseURL, + }); + } + + async chat(params: ChatParams): Promise { + const response = await this.client.chat.completions.create({ + model: params.model, + max_completion_tokens: params.maxTokens, + temperature: params.temperature, + messages: [ + { role: "system" as const, content: params.system }, + ...params.messages.map(toOpenAIMessage), + ], + tools: params.tools.length > 0 ? params.tools.map(toOpenAITool) : undefined, + tool_choice: params.tools.length > 0 ? "auto" : undefined, + }); + + const choice = response.choices[0]; + const content: ContentBlock[] = []; + + if (choice.message.content) { + content.push({ type: "text", text: choice.message.content }); + } + + if (choice.message.tool_calls) { + for (const tc of choice.message.tool_calls) { + content.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments), + }); + } + } + + return { + content, + stopReason: + choice.finish_reason === "tool_calls" + ? "tool_use" + : choice.finish_reason === "length" + ? "max_tokens" + : "end_turn", + usage: { + input: response.usage?.prompt_tokens ?? 0, + output: response.usage?.completion_tokens ?? 0, + }, + }; + } +} + +function toOpenAITool(tool: ToolDefinition): OpenAI.ChatCompletionTool { + return { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema as OpenAI.FunctionParameters, + }, + }; +} + +function toOpenAIMessage( + msg: ChatMessage, +): OpenAI.ChatCompletionMessageParam { + if (msg.role === "tool_result") { + return { + role: "tool", + tool_call_id: msg.toolUseId, + content: msg.content, + }; + } + + if (msg.role === "assistant") { + const toolCalls = msg.content + .filter( + (b): b is ContentBlock & { type: "tool_use" } => b.type === "tool_use", + ) + .map((b) => ({ + id: b.id, + type: "function" as const, + function: { name: b.name, arguments: JSON.stringify(b.input) }, + })); + + const text = msg.content + .filter((b) => b.type === "text") + .map((b) => (b as { type: "text"; text: string }).text) + .join(""); + + return { + role: "assistant", + content: text || null, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }; + } + + return { + role: "user", + content: typeof msg.content === "string" + ? msg.content + : msg.content + .filter((b) => b.type === "text") + .map((b) => (b as { type: "text"; text: string }).text) + .join("\n"), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a656ab..83b75c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: drizzle-orm: specifier: ^0.35.0 version: 0.35.3(@libsql/client-wasm@0.17.2)(@types/pg@8.20.0)(pg@8.20.0) + openai: + specifier: ^4.70.0 + version: 4.104.0(zod@3.25.76) pino: specifier: ^9.6.0 version: 9.14.0 From e85986def75ff81e67cc1baf7012011caed244e3 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 11:23:01 +0100 Subject: [PATCH 04/11] feat(harness): ship model_harness contract + full verification Drops tests/contracts/model_harness.yml with 2 enforced rules: - HARNESS-001: agent.ts, conversation.ts, reasoning-loop.ts must not import @anthropic-ai/sdk or openai directly - HARNESS-002: providers must not import @lexius/core, db, or infra 18 contracts, 41 rules, all passing. 183 core + 36 API + 51 fetcher tests green. Agent smoke-tested: tool calls work through the provider interface. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/contracts/model_harness.yml | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/contracts/model_harness.yml diff --git a/tests/contracts/model_harness.yml b/tests/contracts/model_harness.yml new file mode 100644 index 0000000..95cc6b4 --- /dev/null +++ b/tests/contracts/model_harness.yml @@ -0,0 +1,49 @@ +contract_meta: + id: model_harness + version: 1 + created_from_spec: "PRD-011 / ARD-015 / DDD-014 — model harness provider abstraction" + covers_reqs: + - HARNESS-001 + - HARNESS-002 + owner: "legal-ai-team" + +llm_policy: + enforce: true + llm_may_modify_non_negotiables: false + override_phrase: "override_contract: model_harness" + +rules: + non_negotiable: + - id: HARNESS-001 + title: "Agent must not import provider SDKs directly" + scope: + - "packages/agent/src/agent.{ts,js}" + - "packages/agent/src/conversation.{ts,js}" + - "packages/agent/src/reasoning-loop.{ts,js}" + behavior: + forbidden_patterns: + - pattern: /from\s+['"](?:@anthropic-ai\/sdk|openai)['"]/ + message: "Agent code must use the CompletionProvider interface, not import SDKs directly. SDK imports belong in packages/agent/src/providers/*.ts only." + example_violation: | + import Anthropic from "@anthropic-ai/sdk"; + const client = new Anthropic(); + example_compliant: | + import { createProvider } from "./providers/index.js"; + const provider = createProvider(); + + - id: HARNESS-002 + title: "Provider implementations must not import from domain or infrastructure" + scope: + - "packages/agent/src/providers/**/*.{ts,js}" + behavior: + forbidden_patterns: + - pattern: /from\s+['"]@lexius\/core/ + message: "Providers are pure LLM wrappers — they must not depend on domain logic" + - pattern: /from\s+['"]@lexius\/db/ + message: "Providers must not access the database" + - pattern: /from\s+['"]@lexius\/infra/ + message: "Providers must not depend on infrastructure" + example_violation: | + import { ProvenanceTier } from "@lexius/core"; + example_compliant: | + import type { CompletionProvider, ChatParams } from "./types.js"; From dc4d5d87917a37731df55af2b38179e34bf9dabc Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 13:10:50 +0100 Subject: [PATCH 05/11] feat(harness): --provider CLI flag + async factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent now accepts --provider flag for provider selection: npx @robotixai/lexius-agent --provider openai npx @robotixai/lexius-agent --provider ollama npx @robotixai/lexius-agent --provider anthropic (default) npx @robotixai/lexius-agent (default) MCP and CLI use LEXIUS_MODEL_PROVIDER env var (set in Claude Desktop config or shell). Fixed createProvider to use dynamic import() instead of require() (package is ESM). Model selection threaded through conversation → createAgent → provider.chat() with LEXIUS_MODEL override support. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent/src/agent.ts | 10 ++++++---- packages/agent/src/conversation.ts | 8 +++++--- packages/agent/src/index.ts | 17 ++++++++++++++++- packages/agent/src/providers/index.ts | 10 +++++----- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b0df04c..53f0745 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -302,6 +302,7 @@ export function createAgent( container: Container, config?: AgentConfig, provider?: CompletionProvider, + modelOverride?: string, ) { const llm = provider ?? new AnthropicProvider(); @@ -317,10 +318,11 @@ export function createAgent( async function chat( messages: ChatMessage[], ): Promise { - const model = - process.env.ANTHROPIC_MODEL_REASONING || - process.env.ANTHROPIC_MODEL || - "claude-sonnet-4-6"; + const model = modelOverride + || process.env.LEXIUS_MODEL + || process.env.ANTHROPIC_MODEL_REASONING + || process.env.ANTHROPIC_MODEL + || "claude-sonnet-4-6"; logger.debug({ model }, "Calling LLM via provider"); const response = await llm.chat({ diff --git a/packages/agent/src/conversation.ts b/packages/agent/src/conversation.ts index c4e228e..fa7a26f 100644 --- a/packages/agent/src/conversation.ts +++ b/packages/agent/src/conversation.ts @@ -3,7 +3,7 @@ import type { createContainer } from "@lexius/core"; import { createAgent, type AgentConfig } from "./agent.js"; import { ReasoningLoop } from "./reasoning-loop.js"; import { logger } from "./logger.js"; -import type { ChatMessage } from "./providers/types.js"; +import type { CompletionProvider, ChatMessage } from "./providers/types.js"; type Container = ReturnType; @@ -11,10 +11,12 @@ export async function startConversation( container: Container, cleanup: () => Promise, config?: AgentConfig, + provider?: CompletionProvider, + model?: string, ): Promise { - logger.info("Conversation started"); + logger.info({ provider: provider?.constructor.name, model }, "Conversation started"); - const agent = createAgent(container, config); + const agent = createAgent(container, config, provider, model); const messages: ChatMessage[] = []; const rl = readline.createInterface({ diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index d5a4ed3..b00690f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,18 +1,33 @@ +#!/usr/bin/env node import { setup } from "./setup.js"; import { startConversation } from "./conversation.js"; +import { createProvider, getDefaultModel } from "./providers/index.js"; export { createAgent, loadAgentConfig, type AgentConfig } from "./agent.js"; export { AuditAgent } from "./audit-agent.js"; export { AnthropicEnhancementService } from "./anthropic-enhancement-service.js"; export { ReasoningLoop } from "./reasoning-loop.js"; +export { createProvider, getDefaultModel } from "./providers/index.js"; +export type { CompletionProvider, ChatMessage, ChatResponse, ContentBlock, ToolDefinition } from "./providers/types.js"; // Swarm module export { runSwarm, synthesise, createSwarmSession, cleanupSession } from "./swarm/index.js"; export type { SwarmResult, SwarmFinding, FindingType } from "./swarm/index.js"; async function main() { + const args = process.argv.slice(2); + let providerName: string | undefined; + + const providerIdx = args.indexOf("--provider"); + if (providerIdx !== -1 && args[providerIdx + 1]) { + providerName = args[providerIdx + 1]; + } + + const provider = await createProvider(providerName); + const model = getDefaultModel(providerName); + const { container, config, cleanup } = await setup(); - await startConversation(container, cleanup, config); + await startConversation(container, cleanup, config, provider, model); } main().catch((error) => { diff --git a/packages/agent/src/providers/index.ts b/packages/agent/src/providers/index.ts index 0e87255..7656ef4 100644 --- a/packages/agent/src/providers/index.ts +++ b/packages/agent/src/providers/index.ts @@ -14,24 +14,24 @@ export { MockProvider } from "./mock.js"; import type { CompletionProvider } from "./types.js"; -export function createProvider(override?: string): CompletionProvider { +export async function createProvider(override?: string): Promise { const provider = override || process.env.LEXIUS_MODEL_PROVIDER || "anthropic"; switch (provider) { case "anthropic": { - const { AnthropicProvider } = require("./anthropic.js"); + const { AnthropicProvider } = await import("./anthropic.js"); return new AnthropicProvider(process.env.ANTHROPIC_API_KEY); } case "openai": { - const { OpenAIProvider } = require("./openai.js"); + const { OpenAIProvider } = await import("./openai.js"); return new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }); } case "ollama": { - const { OllamaProvider } = require("./ollama.js"); + const { OllamaProvider } = await import("./ollama.js"); return new OllamaProvider(); } case "mock": { - const { MockProvider } = require("./mock.js"); + const { MockProvider } = await import("./mock.js"); return new MockProvider(); } default: From 1fa0a97959b7467aa592583970f050e94f7f7d24 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 13:17:20 +0100 Subject: [PATCH 06/11] fix(harness): remove double shebang from agent bundle --- packages/agent/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index b00690f..3236f98 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node import { setup } from "./setup.js"; import { startConversation } from "./conversation.js"; import { createProvider, getDefaultModel } from "./providers/index.js"; From f19b21ffbff02fe760d14bbce506f7947b4ca26b Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 13:26:11 +0100 Subject: [PATCH 07/11] fix(harness): case-insensitive --provider + arg validation - --provider Anthropic now works (normalised to lowercase) - --provider with no value gives a clear error instead of silently falling through to default - --provider --verbose no longer treats --verbose as a provider name Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent/src/index.ts | 9 +++++++-- packages/agent/src/providers/index.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3236f98..100fc4a 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -18,8 +18,13 @@ async function main() { let providerName: string | undefined; const providerIdx = args.indexOf("--provider"); - if (providerIdx !== -1 && args[providerIdx + 1]) { - providerName = args[providerIdx + 1]; + if (providerIdx !== -1) { + const val = args[providerIdx + 1]; + if (!val || val.startsWith("-")) { + console.error("--provider requires a value: anthropic, openai, ollama, mock"); + process.exit(1); + } + providerName = val.toLowerCase(); } const provider = await createProvider(providerName); diff --git a/packages/agent/src/providers/index.ts b/packages/agent/src/providers/index.ts index 7656ef4..9d4b030 100644 --- a/packages/agent/src/providers/index.ts +++ b/packages/agent/src/providers/index.ts @@ -15,7 +15,7 @@ export { MockProvider } from "./mock.js"; import type { CompletionProvider } from "./types.js"; export async function createProvider(override?: string): Promise { - const provider = override || process.env.LEXIUS_MODEL_PROVIDER || "anthropic"; + const provider = (override || process.env.LEXIUS_MODEL_PROVIDER || "anthropic").toLowerCase(); switch (provider) { case "anthropic": { From f7e3133764a0758728a715c468ff4453ca983ed4 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 13:29:54 +0100 Subject: [PATCH 08/11] fix(harness): enhancement service uses provider + JSON.parse safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from simulation testing: 1. AnthropicEnhancementService now accepts a CompletionProvider instead of importing @anthropic-ai/sdk directly. Defaults to AnthropicProvider if none passed. ReasoningLoop passes its own provider through, so --provider openai works end-to-end for both chat and enhancement. 2. OpenAI provider wraps JSON.parse(tc.function.arguments) in try-catch — malformed arguments from the model produce { _raw: "..." } instead of crashing. 3. .env.example updated with all provider env vars and comments clarifying that LEXIUS_MODEL_PROVIDER only affects the agent. Zero @anthropic-ai/sdk imports remain outside providers/. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 17 +++++++++++++++- .../src/anthropic-enhancement-service.ts | 20 +++++++++++-------- packages/agent/src/providers/openai.ts | 8 +++++++- packages/agent/src/reasoning-loop.ts | 2 +- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index bfd285a..4e603e6 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,25 @@ +# Database DATABASE_URL=postgresql://legal_ai:changeme@localhost:5432/legal_ai -OPENAI_API_KEY= DB_PASSWORD=changeme + +# LLM provider (agent only — MCP/CLI/API don't use the LLM) +# Or use: npx @robotixai/lexius-agent --provider openai +LEXIUS_MODEL_PROVIDER=anthropic +LEXIUS_MODEL= + +# Anthropic (default provider) ANTHROPIC_API_KEY= ANTHROPIC_MODEL=claude-sonnet-4-6 ANTHROPIC_MODEL_REASONING=claude-opus-4-6 ANTHROPIC_MODEL_STRUCTURED=claude-sonnet-4-6 + +# OpenAI (also used for embeddings) +OPENAI_API_KEY= + +# Ollama (local, no key needed) +OLLAMA_URL=http://localhost:11434/v1 + +# API server PORT=3000 NODE_ENV=development LOG_LEVEL=info diff --git a/packages/agent/src/anthropic-enhancement-service.ts b/packages/agent/src/anthropic-enhancement-service.ts index 3c43ea2..8680d27 100644 --- a/packages/agent/src/anthropic-enhancement-service.ts +++ b/packages/agent/src/anthropic-enhancement-service.ts @@ -1,7 +1,8 @@ -import Anthropic from "@anthropic-ai/sdk"; import type { ComplianceReport } from "@lexius/core"; import type { ReportEnhancement } from "@lexius/core"; import { logger } from "./logger.js"; +import { AnthropicProvider } from "./providers/anthropic.js"; +import type { CompletionProvider } from "./providers/types.js"; const ENHANCEMENT_SYSTEM_PROMPT = `You are a senior AI regulatory compliance consultant. You have been given a structured compliance assessment report. Analyse it and provide: @@ -31,23 +32,26 @@ Respond ONLY with valid JSON in this exact format: }`; export class AnthropicEnhancementService { - private readonly client: Anthropic; + private readonly provider: CompletionProvider; private readonly model: string; - constructor() { - this.client = new Anthropic(); - this.model = process.env.ANTHROPIC_MODEL_STRUCTURED + constructor(provider?: CompletionProvider) { + this.provider = provider ?? new AnthropicProvider(); + this.model = process.env.LEXIUS_MODEL + || process.env.ANTHROPIC_MODEL_STRUCTURED || process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6"; } async enhance(report: ComplianceReport, systemDescription: string): Promise { - logger.debug({ model: this.model }, "Calling Anthropic for report enhancement"); + logger.debug({ model: this.model }, "Calling LLM for report enhancement"); - const response = await this.client.messages.create({ + const response = await this.provider.chat({ model: this.model, - max_tokens: 2048, + maxTokens: 2048, + temperature: 0, system: ENHANCEMENT_SYSTEM_PROMPT, + tools: [], messages: [{ role: "user", content: `AI System Description:\n${systemDescription}\n\nCompliance Assessment Report:\n${JSON.stringify(report, null, 2)}`, diff --git a/packages/agent/src/providers/openai.ts b/packages/agent/src/providers/openai.ts index 7d8888a..e98bdca 100644 --- a/packages/agent/src/providers/openai.ts +++ b/packages/agent/src/providers/openai.ts @@ -40,11 +40,17 @@ export class OpenAIProvider implements CompletionProvider { if (choice.message.tool_calls) { for (const tc of choice.message.tool_calls) { + let input: Record = {}; + try { + input = JSON.parse(tc.function.arguments); + } catch { + input = { _raw: tc.function.arguments }; + } content.push({ type: "tool_use", id: tc.id, name: tc.function.name, - input: JSON.parse(tc.function.arguments), + input, }); } } diff --git a/packages/agent/src/reasoning-loop.ts b/packages/agent/src/reasoning-loop.ts index 353328b..0f6d7ea 100644 --- a/packages/agent/src/reasoning-loop.ts +++ b/packages/agent/src/reasoning-loop.ts @@ -39,7 +39,7 @@ export class ReasoningLoop { constructor(container: Container, provider?: CompletionProvider) { this.container = container; this.llm = provider ?? new AnthropicProvider(); - this.enhancementService = new AnthropicEnhancementService(); + this.enhancementService = new AnthropicEnhancementService(this.llm); this.context = { systemDescription: "", role: "unknown", From eeb48f8749eb28dcab020c1a7715fb3d463f57b0 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 19:27:27 +0100 Subject: [PATCH 09/11] docs: add OpenRouter provider to model harness specs Updates PRD-011, ARD-015, and DDD-014 with OpenRouterProvider: - Thin subclass of OpenAIProvider (same as Ollama pattern) - baseURL: https://openrouter.ai/api/v1 - Single API key for hundreds of models (Claude, GPT-4, Llama, Gemini, Mistral, etc.) - Model selection via LEXIUS_MODEL (e.g., anthropic/claude-sonnet-4, openai/gpt-4o, meta-llama/llama-3-70b) - Default model: anthropic/claude-sonnet-4 Usage: npx @robotixai/lexius-agent --provider openrouter Factory, env vars, package structure all updated across the three spec docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ard/ARD-015-model-harness.md | 19 +++++++++ docs/ddd/DDD-014-model-harness.md | 66 ++++++++++++++++++++++++++----- docs/prd/PRD-011-model-harness.md | 7 +++- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/docs/ard/ARD-015-model-harness.md b/docs/ard/ARD-015-model-harness.md index a194046..725a4ac 100644 --- a/docs/ard/ARD-015-model-harness.md +++ b/docs/ard/ARD-015-model-harness.md @@ -75,6 +75,25 @@ Ollama exposes an OpenAI-compatible `/v1/chat/completions` endpoint. `OllamaProv No separate SDK dependency. Same `openai` package, different config. +### 4a. OpenRouter reuses OpenAI provider with unified model access + +OpenRouter (`openrouter.ai`) provides a single OpenAI-compatible API that routes to hundreds of models across providers (Anthropic, OpenAI, Meta, Google, Mistral, etc.). One API key, one endpoint, unified billing. + +`OpenRouterProvider` extends `OpenAIProvider` with: +- `baseURL: "https://openrouter.ai/api/v1"` +- `apiKey: process.env.OPENROUTER_API_KEY` +- `defaultModel: "anthropic/claude-sonnet-4"` (or whatever the user configures via `LEXIUS_MODEL`) + +Same pattern as Ollama — a thin subclass, no new SDK dependency. The `openai` package works directly against OpenRouter's endpoint. + +Why this matters: +- **Single key for everything** — users don't need separate Anthropic, OpenAI, and Google keys. One OpenRouter key accesses all of them. +- **Model comparison** — run the same compliance query against `anthropic/claude-sonnet-4`, `openai/gpt-4o`, and `google/gemini-2.0-flash` to compare tool-use quality. The harness handles it transparently. +- **Cost routing** — OpenRouter supports model fallbacks and cost-optimised routing. A user can configure `auto` as the model and let OpenRouter pick the cheapest model that handles tool-use. +- **No vendor relationship needed** — useful for users who can't or won't sign up directly with each provider. + +Rejected: building a custom multi-provider router. OpenRouter already solves this at the API level; wrapping it is ~5 lines of code. + ### 5. Provider selection is a factory function, not dependency injection ```typescript diff --git a/docs/ddd/DDD-014-model-harness.md b/docs/ddd/DDD-014-model-harness.md index fc98689..0e35718 100644 --- a/docs/ddd/DDD-014-model-harness.md +++ b/docs/ddd/DDD-014-model-harness.md @@ -7,7 +7,20 @@ ## Overview -Implementation details for PRD-011 / ARD-015. Covers: normalised types, three provider implementations, factory, agent refactor, mock for testing, and the Specflow contract. +Implementation details for PRD-011 / ARD-015. Covers: normalised types, five provider implementations (Anthropic, OpenAI, OpenRouter, Ollama, Mock), factory, agent refactor, and the Specflow contract. + +## Package Structure + +``` +packages/agent/src/providers/ +├── types.ts -- ChatParams, ChatResponse, ContentBlock, ToolDefinition +├── anthropic.ts -- AnthropicProvider (native SDK) +├── openai.ts -- OpenAIProvider (OpenAI SDK) +├── openrouter.ts -- OpenRouterProvider (extends OpenAI, routes to any model) +├── ollama.ts -- OllamaProvider (extends OpenAI, local models) +├── mock.ts -- MockProvider (canned responses for tests) +└── index.ts -- createProvider factory + getDefaultModel +``` ## Normalised Types @@ -242,6 +255,35 @@ export class OllamaProvider extends OpenAIProvider { Three lines. Ollama's API is OpenAI-compatible. +## OpenRouterProvider + +```typescript +// packages/agent/src/providers/openrouter.ts +import { OpenAIProvider } from "./openai.js"; + +export class OpenRouterProvider extends OpenAIProvider { + constructor() { + super({ + apiKey: process.env.OPENROUTER_API_KEY, + baseURL: "https://openrouter.ai/api/v1", + }); + } +} +``` + +Same pattern as Ollama — a thin subclass. OpenRouter's API is fully OpenAI-compatible with tool-use support. Users select models via the standard model parameter using OpenRouter's naming convention: + +```bash +npx @robotixai/lexius-agent --provider openrouter +# defaults to anthropic/claude-sonnet-4 + +LEXIUS_MODEL=openai/gpt-4o npx @robotixai/lexius-agent --provider openrouter +LEXIUS_MODEL=meta-llama/llama-3-70b npx @robotixai/lexius-agent --provider openrouter +LEXIUS_MODEL=google/gemini-2.0-flash npx @robotixai/lexius-agent --provider openrouter +``` + +One API key, any model. No need for separate provider accounts. + ## MockProvider ```typescript @@ -289,6 +331,10 @@ export function createProvider(override?: string): CompletionProvider { const { OpenAIProvider } = require("./openai.js"); return new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }); } + case "openrouter": { + const { OpenRouterProvider } = require("./openrouter.js"); + return new OpenRouterProvider(); + } case "ollama": { const { OllamaProvider } = require("./ollama.js"); return new OllamaProvider(); @@ -299,18 +345,19 @@ export function createProvider(override?: string): CompletionProvider { } default: throw new Error( - `Unknown model provider: ${provider}. Valid: anthropic, openai, ollama, mock`, + `Unknown model provider: ${provider}. Valid: anthropic, openai, openrouter, ollama, mock`, ); } } export function getDefaultModel(provider?: string): string { switch (provider || process.env.LEXIUS_MODEL_PROVIDER || "anthropic") { - case "anthropic": return process.env.LEXIUS_MODEL || "claude-sonnet-4-6"; - case "openai": return process.env.LEXIUS_MODEL || "gpt-4o"; - case "ollama": return process.env.LEXIUS_MODEL || "llama3"; - case "mock": return "mock"; - default: return "claude-sonnet-4-6"; + case "anthropic": return process.env.LEXIUS_MODEL || "claude-sonnet-4-6"; + case "openai": return process.env.LEXIUS_MODEL || "gpt-4o"; + case "openrouter": return process.env.LEXIUS_MODEL || "anthropic/claude-sonnet-4"; + case "ollama": return process.env.LEXIUS_MODEL || "llama3"; + case "mock": return "mock"; + default: return "claude-sonnet-4-6"; } } ``` @@ -364,10 +411,11 @@ The recursive tool-use loop stays identical. Only the types change from `Anthrop | Variable | Default | Description | |----------|---------|-------------| -| `LEXIUS_MODEL_PROVIDER` | `anthropic` | Provider: `anthropic`, `openai`, `ollama`, `mock` | -| `LEXIUS_MODEL` | per-provider | Model override | +| `LEXIUS_MODEL_PROVIDER` | `anthropic` | Provider: `anthropic`, `openai`, `openrouter`, `ollama`, `mock` | +| `LEXIUS_MODEL` | per-provider | Model override (e.g., `anthropic/claude-sonnet-4` for OpenRouter) | | `ANTHROPIC_API_KEY` | -- | Required for `anthropic` provider | | `OPENAI_API_KEY` | -- | Required for `openai` provider | +| `OPENROUTER_API_KEY` | -- | Required for `openrouter` provider. Single key for all models. | | `OLLAMA_URL` | `http://localhost:11434/v1` | Ollama API URL | ## Testing Strategy diff --git a/docs/prd/PRD-011-model-harness.md b/docs/prd/PRD-011-model-harness.md index bd4265c..e8d029f 100644 --- a/docs/prd/PRD-011-model-harness.md +++ b/docs/prd/PRD-011-model-harness.md @@ -70,11 +70,14 @@ Cost-based routing is an optional layer on top: a router examines the user's que 6. **`OllamaProvider`** — wraps Ollama's OpenAI-compatible API (`http://localhost:11434/v1/chat/completions`). Uses the same translation as `OpenAIProvider` but with Ollama-specific defaults (no API key, local URL). Supports any model available in Ollama (llama3, mistral, qwen, etc.). -7. **`MockProvider`** — returns canned responses for testing. Configurable: can return a fixed text response, a fixed tool_use response, or echo the input. Used in unit tests so they don't hit any API. +7. **`OpenRouterProvider`** — wraps OpenRouter's OpenAI-compatible API (`https://openrouter.ai/api/v1`). Uses the same translation as `OpenAIProvider` but with OpenRouter-specific defaults. Provides access to hundreds of models (Claude, GPT-4, Llama, Mistral, Gemini, etc.) through a single API key and unified billing. The key advantage: users can switch between any model from any provider without managing multiple API keys. Supports model routing via the standard model parameter (e.g., `anthropic/claude-sonnet-4`, `openai/gpt-4o`, `meta-llama/llama-3-70b`). -8. **Provider selection via environment** — `LEXIUS_MODEL_PROVIDER` env var selects the provider: +8. **`MockProvider`** — returns canned responses for testing. Configurable: can return a fixed text response, a fixed tool_use response, or echo the input. Used in unit tests so they don't hit any API. + +9. **Provider selection via environment** — `LEXIUS_MODEL_PROVIDER` env var or `--provider` CLI flag selects the provider: - `anthropic` (default) — uses `ANTHROPIC_API_KEY` - `openai` — uses `OPENAI_API_KEY` (the same one used for embeddings) + - `openrouter` — uses `OPENROUTER_API_KEY`. Access any model via a single key. - `ollama` — uses `OLLAMA_URL` (default `http://localhost:11434`) - `mock` — no API key needed From 2ef62ec3cfcd60a28697638c11f0726198e39a0f Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 19 Apr 2026 19:30:43 +0100 Subject: [PATCH 10/11] feat(harness): add OpenRouterProvider Thin subclass of OpenAIProvider pointing at openrouter.ai/api/v1. Single API key for hundreds of models (Claude, GPT-4, Llama, Gemini). npx @robotixai/lexius-agent --provider openrouter LEXIUS_MODEL=openai/gpt-4o npx @robotixai/lexius-agent --provider openrouter Default model: anthropic/claude-sonnet-4. OPENROUTER_API_KEY env var added to .env.example. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 3 +++ packages/agent/src/providers/index.ts | 18 ++++++++++++------ packages/agent/src/providers/openrouter.ts | 10 ++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 packages/agent/src/providers/openrouter.ts diff --git a/.env.example b/.env.example index 4e603e6..99414ed 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ ANTHROPIC_MODEL_STRUCTURED=claude-sonnet-4-6 # OpenAI (also used for embeddings) OPENAI_API_KEY= +# OpenRouter (single key for any model — Claude, GPT-4, Llama, Gemini, etc.) +OPENROUTER_API_KEY= + # Ollama (local, no key needed) OLLAMA_URL=http://localhost:11434/v1 diff --git a/packages/agent/src/providers/index.ts b/packages/agent/src/providers/index.ts index 9d4b030..bfa558a 100644 --- a/packages/agent/src/providers/index.ts +++ b/packages/agent/src/providers/index.ts @@ -10,6 +10,7 @@ export type { export { AnthropicProvider } from "./anthropic.js"; export { OpenAIProvider } from "./openai.js"; export { OllamaProvider } from "./ollama.js"; +export { OpenRouterProvider } from "./openrouter.js"; export { MockProvider } from "./mock.js"; import type { CompletionProvider } from "./types.js"; @@ -26,6 +27,10 @@ export async function createProvider(override?: string): Promise Date: Sun, 19 Apr 2026 19:38:02 +0100 Subject: [PATCH 11/11] docs: update all docs for model harness + OpenRouter - README: contract count 20/45 rules, model_harness in contracts table, doc counts verified (12 PRDs, 16 ARDs, 15 DDDs) - Agent README: added --provider flag, OpenRouter, env vars for all 5 providers - CHANGELOG: added [Unreleased] section with model harness + offshore CIMA - .env.example: OpenRouter in provider comment - Fixed duplicate index entries (ARD-015, DDD-014) from rebase Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 7 ++++--- docs/ard/INDEX.md | 1 - docs/ddd/INDEX.md | 1 - packages/agent/README.md | 19 +++++++++++++------ 6 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 99414ed..58e8f6a 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ DATABASE_URL=postgresql://legal_ai:changeme@localhost:5432/legal_ai DB_PASSWORD=changeme # LLM provider (agent only — MCP/CLI/API don't use the LLM) -# Or use: npx @robotixai/lexius-agent --provider openai +# Or use: npx @robotixai/lexius-agent --provider openai|openrouter|ollama LEXIUS_MODEL_PROVIDER=anthropic LEXIUS_MODEL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 55697be..ff5b80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/). Versioning follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- **Model harness** — provider-agnostic LLM abstraction. The agent no longer imports `@anthropic-ai/sdk` directly; it uses a `CompletionProvider` interface with 5 implementations: + - `AnthropicProvider` (default) + - `OpenAIProvider` (GPT-4o, o1, o3) + - `OpenRouterProvider` (single key for any model — Claude, GPT-4, Llama, Gemini) + - `OllamaProvider` (local models, no API key) + - `MockProvider` (canned responses for testing) + - `--provider` CLI flag: `npx @robotixai/lexius-agent --provider openrouter` + - Specflow contract `model_harness.yml` (2 rules: no SDK imports in agent code, providers don't import domain) +- **Offshore CIMA** — 10 Cayman Islands acts ingested via PDF source adapter. 650 sections, 1,200 extracts (88 KYD fines, 28 imprisonment terms, 1,082 shall-clauses). + - Source adapter interface (`CellarAdapter` + `PdfAdapter`) + - Common-law section parser with title/body merge + dynamic header detection + - CIMA registry (10 acts with verified PDF URLs) + - `fine_amount_kyd` + `imprisonment_term` extract types (migration 0005) + - Specflow contract `offshore_adapters.yml` (4 rules) + ## [0.3.0] - 2026-04-17 ### Added diff --git a/README.md b/README.md index dd10cac..076c8d4 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ npx @robotixai/lexius-cli audit --legislation eu-ai-act --description "recruitme ## Contract Enforcement -19 contracts, 43 rules enforced by [Specflow](https://www.npmjs.com/package/@robotixai/specflow-cli): +20 contracts, 45 rules enforced by [Specflow](https://www.npmjs.com/package/@robotixai/specflow-cli): ```bash npx @robotixai/specflow-cli enforce . @@ -348,6 +348,7 @@ npx @robotixai/specflow-cli enforce . | **Integration** | `integration_security` | No key hashes in responses; SSE uses auth | | **Swarm** | `hivemind_swarm` | No LLM in agent loop; atomic claims; cleanup complete | | **Offshore** | `offshore_adapters` | No LLM in PDF parsing; source_format=pdf; section merge; dynamic header detection | +| **Model Harness** | `model_harness` | No direct SDK imports in agent code; providers don't import domain | | **Fetcher** | `fetcher_verbatim` | Records sourceHash + fetchedAt | | **Audit** | `audit_report_integrity`, `audit_enhancement_layer`, `audit_agent_layer` | GenerateAuditReport is deterministic; enhancement via port | | **Security** | `security_secrets`, `security_sql_safety`, `security_input_validation`, `security_no_eval` | No hardcoded creds; parameterised queries; Zod validation | @@ -361,7 +362,7 @@ pnpm --filter @lexius/core test # 183 unit tests pnpm --filter @lexius/api test # 36 functional tests pnpm --filter @lexius/fetcher test # 78 extractor + parser tests pnpm crosscheck # Penalty cross-check vs verbatim law -npx @robotixai/specflow-cli enforce . # 19 contracts, 43 rules +npx @robotixai/specflow-cli enforce . # 20 contracts, 45 rules ``` ## Documentation @@ -399,7 +400,7 @@ Full spec documents in `docs/`: - **Bundler:** esbuild - **Monorepo:** Turborepo + pnpm workspaces - **PDF Parsing:** pdfjs-dist (offshore legislation) -- **Contracts:** Specflow (19 contracts, 43 rules) +- **Contracts:** Specflow (20 contracts, 45 rules) - **Testing:** Vitest + Supertest (297 tests) ## License diff --git a/docs/ard/INDEX.md b/docs/ard/INDEX.md index 3e18ad8..7bd1b20 100644 --- a/docs/ard/INDEX.md +++ b/docs/ard/INDEX.md @@ -16,4 +16,3 @@ - [ARD-014: Hivemind Swarm](ARD-014-hivemind-swarm.md) — Postgres-backed stigmergic swarm, Promise.all concurrency, deterministic agents, gap detection - [ARD-015: Model Harness](ARD-015-model-harness.md) — single CompletionProvider interface, provider-internal translation, factory selection via env var - [ARD-016: Offshore CIMA](ARD-016-offshore-cima.md) — PDF source adapter, common-law section parser, CIMA registry, pdfjs-dist -- [ARD-015: Model Harness](ARD-015-model-harness.md) — single CompletionProvider interface, provider-internal translation, factory selection via env var diff --git a/docs/ddd/INDEX.md b/docs/ddd/INDEX.md index a28fd74..d05ca95 100644 --- a/docs/ddd/INDEX.md +++ b/docs/ddd/INDEX.md @@ -15,4 +15,3 @@ - [DDD-013: Hivemind Swarm](DDD-013-hivemind-swarm.md) — compliance_workspace + swarm_work_queue tables, agent loop, gap detector, synthesis, API/MCP integration - [DDD-014: Model Harness](DDD-014-model-harness.md) — AnthropicProvider, OpenAIProvider, OllamaProvider, MockProvider, factory, agent refactor - [DDD-015: Offshore CIMA](DDD-015-offshore-cima.md) — PdfAdapter, section parser, CIMA registry, dollar/imprisonment extractors, ingest refactor -- [DDD-014: Model Harness](DDD-014-model-harness.md) — AnthropicProvider, OpenAIProvider, OllamaProvider, MockProvider, factory, agent refactor diff --git a/packages/agent/README.md b/packages/agent/README.md index 14b90e2..be361d8 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -1,6 +1,6 @@ # @robotixai/lexius-agent -Interactive AI compliance consultant for the [Lexius](https://github.com/rob-otix-ai/lexius) platform. Powered by Claude with deterministic tool use — every factual claim comes from the database, not the model's training data. +Interactive AI compliance consultant for the [Lexius](https://github.com/rob-otix-ai/lexius) platform. Provider-agnostic — works with Anthropic, OpenAI, OpenRouter, or Ollama. Every factual claim comes from the database, not the model's training data. ## Quick Start @@ -12,10 +12,15 @@ docker run -d -p 5432:5432 \ -e POSTGRES_USER=$POSTGRES_USER \ robotixai/lexius-db -# 2. Run the agent +# 2. Run the agent (default: Anthropic) export DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB export ANTHROPIC_API_KEY=sk-ant-... npx @robotixai/lexius-agent + +# Or use a different provider +npx @robotixai/lexius-agent --provider openai # requires OPENAI_API_KEY +npx @robotixai/lexius-agent --provider openrouter # requires OPENROUTER_API_KEY (any model, one key) +npx @robotixai/lexius-agent --provider ollama # local models, no key needed ``` ## What It Does @@ -46,7 +51,7 @@ The agent is configured for maximum reproducibility: | `classify_system` | Risk classification | DB (deterministic) | | `get_obligations` | Obligations by role/risk | DB | | `calculate_penalty` | Penalty calculation | DB + extracted values | -| `get_article` | Verbatim article text | CELLAR (AUTHORITATIVE) | +| `get_article` | Verbatim article text | CELLAR/PDF (AUTHORITATIVE) | | `get_deadlines` | Compliance deadlines | DB | | `search_knowledge` | Semantic search | DB + embeddings | | `answer_question` | FAQ lookup | DB | @@ -72,9 +77,11 @@ await cleanupSession(db, result.sessionId); | Variable | Required | Description | |----------|----------|-------------| | `DATABASE_URL` | Yes | PostgreSQL connection string | -| `ANTHROPIC_API_KEY` | Yes | Anthropic API key | -| `ANTHROPIC_MODEL` | No | Model override (default: `claude-sonnet-4-6`) | -| `OPENAI_API_KEY` | No | For embeddings in semantic search | +| `ANTHROPIC_API_KEY` | --provider anthropic | Anthropic API key (default provider) | +| `OPENAI_API_KEY` | --provider openai | OpenAI API key (also used for embeddings) | +| `OPENROUTER_API_KEY` | --provider openrouter | Single key for any model (Claude, GPT-4, Llama, Gemini) | +| `OLLAMA_URL` | --provider ollama | Ollama API URL (default: localhost:11434) | +| `LEXIUS_MODEL` | No | Override the default model for any provider | ## Links