diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs new file mode 100644 index 0000000..143c464 --- /dev/null +++ b/lib/providers/anthropic.mjs @@ -0,0 +1,453 @@ +/** + * lib/providers/anthropic.mjs — Anthropic Claude provider plugin + * + * Authority: OLP ALIGNMENT.md § Authority 1 — anthropic plugin governed by + * `claude -p` from `@anthropic-ai/claude-code`. + * OCP server.mjs inherits cli.js 2.1.89 audit pin at fork (per ALIGNMENT.md + * § Provider authority pins). + * + * Spawn pattern ported from: + * OCP server.mjs:384-414 (buildCliArgs — -p / --model / --output-format / --no-session-persistence) + * OCP server.mjs:480-607 (spawnClaudeProcess — env cleanup, spawn, stdin write) + * OCP server.mjs:422-468 (messagesToPrompt — text serialization) + * + * Auth reading ported from: + * OCP server.mjs:864-888 (getOAuthCredentials — env → .credentials.json → keychain) + * OCP server.mjs:531-534 (env cleanup: delete ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, + * ANTHROPIC_AUTH_TOKEN before spawn) + * + * Contract version: 1.0 (ADR 0002 § "Provider contract (v1.0 interface)" + D4 contractVersion fold-in) + * + * Status at D4: CANDIDATE. Not yet Enabled per ALIGNMENT.md § Provider Inventory. + * The plugin is present in STATIC_REGISTRY but enabled: false is the default config. + * POST /v1/chat/completions continues to return 503 until D5 flips the enabled flag + * in ~/.olp/config.json after E2E audit passes. + * + * Lossy translations (per ADR 0003 § "Lossy-translation documentation requirement"): + * - `response_format: json_object` — Anthropic `claude -p` does not natively honor this + * field; a system-prompt augmentation is injected ("Reply with valid JSON only."). + * - `top_p` — not mapped to a `claude -p` flag; dropped silently. `claude -p` does not + * expose --top-p. + * - `tool_choice: "required"` — not a `claude -p` flag; dropped. + * - Request-level `tools[]` — `claude -p --output-format text` is a text-in/text-out + * CLI and does not consume structured tool definitions on the wire. The array is + * silently dropped; if a caller wants tool-use they should pre-prompt the model + * about the tools in the system message. ALIGNMENT.md Rule 2 forbids inventing + * a wire format Anthropic's CLI does not accept. + * - Assistant-message `tool_calls` and `tool_call_id` (IR messages representing prior + * turns where the assistant invoked a tool) — same reason as above; dropped. + * If a multi-turn conversation includes tool-call history, the textual content is + * preserved but the structured call metadata is lost. + * + * Quota status: null at D4. Anthropic does not expose a programmatic quota endpoint. + * TODO(2026-06-16): one-shot audit per ALIGNMENT.md § One-shot Triggered Audits. + * After 2026-06-15 Agent SDK Credit billing split takes effect, re-evaluate whether + * a programmatic credit-pool balance API exists and pin it here if found. + */ + +import { spawn as defaultSpawn } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { ProviderError } from './base.mjs'; + +// ── Binary resolution ───────────────────────────────────────────────────── +// OLP_CLAUDE_BIN env takes priority, then falls back to 'claude' from PATH. +// Ported from OCP server.mjs:87-123 (resolveClaude) — simplified for plugin +// scope (OCP's multi-path nvm/fnm/asdf resolution is omitted; OLP plugins +// use the simpler env-override-first pattern and rely on PATH for the rest). +// +// OCP server.mjs:88: if (process.env.CLAUDE_BIN) { ... return process.env.CLAUDE_BIN; } +// OLP uses OLP_CLAUDE_BIN to avoid colliding with OCP's CLAUDE_BIN if both are +// installed on the same machine. +function resolveClaudeBin() { + return process.env.OLP_CLAUDE_BIN || 'claude'; +} + +// ── Auth artifact reading ───────────────────────────────────────────────── +// Ported from OCP server.mjs:864-888 (getOAuthCredentials). +// Priority order (matching OCP exactly): +// 1. CLAUDE_CODE_OAUTH_TOKEN env var — explicit override, highest precedence +// 2. ~/.claude/.credentials.json (Linux file-based path) +// 3. macOS keychain via `security find-generic-password` +// Both label formats tried: "claude-code-credentials" and "Claude Code-credentials" +// +// Returns { accessToken, refreshToken?, expiresAt? } or null. +// Throws nothing — null means "not found, try next". +export function readAuthArtifact() { + // 1. Env var — OCP server.mjs:866-868 + if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { + return { accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN }; + } + + // 2. Linux/cross-platform file-based credentials — OCP server.mjs:871-875 + // Note: OCP uses ~/.claude/.credentials.json (dot-prefix), not ~/.claude/credentials.json. + // This matches the actual path Claude Code writes to. + try { + const credPath = join(homedir(), '.claude', '.credentials.json'); + const raw = readFileSync(credPath, 'utf8'); + const creds = JSON.parse(raw); + if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth; + } catch { /* fall through */ } + + // 3. macOS keychain — OCP server.mjs:877-885 + // Only attempted on darwin to avoid shell-out overhead on other platforms. + if (process.platform === 'darwin') { + for (const label of ['claude-code-credentials', 'Claude Code-credentials']) { + try { + const raw = execFileSync('security', [ + 'find-generic-password', '-s', label, '-w', + ], { encoding: 'utf8', timeout: 5000 }).trim(); + const creds = JSON.parse(raw); + if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth; + } catch { /* try next */ } + } + } + + return null; +} + +// ── IR → prompt text serialization ─────────────────────────────────────── +// OCP server.mjs:422-468 (messagesToPrompt) — no session management in OLP; +// we always pass the full serialized messages as stdin to `claude -p`. +// +// `claude -p` reads its prompt from stdin when no prompt argument is given. +// OCP server.mjs:542 confirms: `proc.stdin.write(prompt); proc.stdin.end();` +// The format OCP uses is: system → "[System] ", assistant → "[Assistant] ", +// user → raw text. We port the same format. +// +// ADR 0003 § Translation direction model: the plugin owns irToNative. +export function irToAnthropic(irRequest) { + const parts = []; + + for (const msg of irRequest.messages) { + const text = typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content); + + if (msg.role === 'system') { + parts.push(`[System] ${text}`); + } else if (msg.role === 'assistant') { + parts.push(`[Assistant] ${text}`); + } else if (msg.role === 'tool') { + // Tool result — pass as annotated user turn so the model knows it's a tool response. + const nameAnnotation = msg.name ? ` (${msg.name})` : ''; + parts.push(`[Tool Result${nameAnnotation}] ${text}`); + } else { + // user + parts.push(text); + } + } + + // Lossy: response_format json_object → system-prompt augmentation. + // ADR 0003 § Lossy-translation documentation requirement. + if (irRequest.response_format?.type === 'json_object') { + parts.unshift('[System] Reply with valid JSON only. Do not include any prose outside the JSON structure.'); + } + + return parts.join('\n\n'); +} + +// ── Anthropic SSE chunk → IR ResponseChunk ──────────────────────────────── +// `claude -p --output-format text` emits raw text to stdout (not NDJSON/SSE). +// This matches OCP server.mjs:735-748: each `proc.stdout.on('data', d => ...)` chunk +// is raw text content — no JSON envelope. The plugin converts raw text chunks to +// IR delta chunks, and produces a synthetic stop chunk at close. +// +// For streaming: each text chunk → IRResponseChunk { type: 'delta', content: } +// For non-streaming: the full accumulated stdout → single text → emit delta + stop. +// +// `claude -p --output-format json` would emit a JSON envelope, but OCP uses `text` +// (OCP server.mjs:385: "--output-format", "text"), so we port the same. +export function anthropicChunkToIR(rawText, isFirstChunk = false) { + return { + type: 'delta', + ...(isFirstChunk && { role: 'assistant' }), + content: rawText, + }; +} + +// Synthesize the stop chunk (called at proc.on('close', ...) with code 0). +export function anthropicStopToIR(finishReason = 'stop') { + return { + type: 'stop', + finish_reason: finishReason, + }; +} + +// ── CLI args builder ────────────────────────────────────────────────────── +// Ported from OCP server.mjs:384-414 (buildCliArgs), stripped of session +// management (OLP is stateless per AGENTS.md § "No conversation state"). +// +// OCP server.mjs:385: const args = ["-p", "--model", cliModel, "--output-format", "text"]; +// OCP server.mjs:392: args.push("--no-session-persistence"); // when no sessionInfo +// OCP server.mjs:395-400: --dangerously-skip-permissions or --allowedTools +// +// OLP variant: always --no-session-persistence (stateless). No permissions flags at D4. +function buildCliArgs(model) { + return [ + '-p', + '--model', model, + '--output-format', 'text', + '--no-session-persistence', + ]; +} + +// ── Env cleanup ─────────────────────────────────────────────────────────── +// OCP server.mjs:531-534: deletes ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, +// ANTHROPIC_AUTH_TOKEN before spawn to prevent env-level override collisions +// with the OAuth token the CLI manages internally. +// OCP server.mjs:530: const env = { ...process.env }; +function buildSpawnEnv() { + const env = { ...process.env }; + // OCP server.mjs:531: delete env.CLAUDECODE; + delete env.CLAUDECODE; + // OCP server.mjs:532: delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_API_KEY; + // OCP server.mjs:533: delete env.ANTHROPIC_BASE_URL; + delete env.ANTHROPIC_BASE_URL; + // OCP server.mjs:534: delete env.ANTHROPIC_AUTH_TOKEN; + delete env.ANTHROPIC_AUTH_TOKEN; + return env; +} + +// ── Spawn function (core) ───────────────────────────────────────────────── +// Returns an AsyncIterator per ADR 0002 § Provider contract. +// `spawnImpl` is swappable for tests (dependency injection, no real binary needed). +// +// OCP server.mjs:542: const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] }); +// OCP server.mjs:586-587: proc.stdin.write(prompt); proc.stdin.end(); +async function* _spawnAndStream(irRequest, authContext, spawnImpl) { + const auth = authContext ?? readAuthArtifact(); + if (!auth?.accessToken) { + throw new ProviderError( + 'No Anthropic OAuth token found. Run `claude auth login` or set CLAUDE_CODE_OAUTH_TOKEN.', + 'AUTH_MISSING', + ); + } + + const bin = resolveClaudeBin(); + const args = buildCliArgs(irRequest.model); + const env = buildSpawnEnv(); + + // Inject OAuth token so the CLI can authenticate. + // OCP server.mjs:864-888: the token is read from keychain/.credentials.json/env + // and passed as CLAUDE_CODE_OAUTH_TOKEN for the spawn invocation. + // We set it explicitly here so the plugin is self-contained. + env.CLAUDE_CODE_OAUTH_TOKEN = auth.accessToken; + + const prompt = irToAnthropic(irRequest); + + // OCP server.mjs:542: spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] }) + const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + + // OCP server.mjs:586-587: proc.stdin.write(prompt); proc.stdin.end(); + proc.stdin.write(prompt); + proc.stdin.end(); + + // Yield IR chunks as they arrive from stdout. + let isFirstChunk = true; + let accumulatedStderr = ''; + + // Convert proc events to an async generator using a push-buffer + signal approach. + const chunks = []; + let done = false; + let exitCode = null; + let exitSignal = null; + let resolveNext = null; + + function push(item) { + chunks.push(item); + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r(); + } + } + + proc.stdout.on('data', (d) => { + const text = d.toString(); + push({ type: 'data', text }); + }); + + proc.stderr.on('data', (d) => { + accumulatedStderr += d.toString(); + }); + + proc.on('error', (err) => { + push({ type: 'error', err }); + }); + + proc.on('close', (code, signal) => { + exitCode = code; + exitSignal = signal; + done = true; + push({ type: 'close' }); + }); + + // Drain the chunk buffer + while (true) { + if (chunks.length === 0) { + if (done) break; + // Wait for the next event + await new Promise((resolve) => { resolveNext = resolve; }); + continue; + } + + const item = chunks.shift(); + + if (item.type === 'error') { + throw new ProviderError(`claude spawn error: ${item.err.message}`, 'SPAWN_FAILED'); + } + + if (item.type === 'close') { + break; + } + + if (item.type === 'data') { + yield anthropicChunkToIR(item.text, isFirstChunk); + isFirstChunk = false; + } + } + + // Process close + if (exitCode !== 0) { + const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`; + // Map known exit patterns to IR error chunk, then throw + throw new ProviderError(errMsg, 'SPAWN_FAILED'); + } + + // Normal exit: emit stop chunk + yield anthropicStopToIR('stop'); +} + +// ── spawn (public, contract method) ────────────────────────────────────── +// The public spawn method conforms to ADR 0002 § Provider contract: +// spawn: async (irRequest, authContext) => AsyncIterator +// +// The `_spawnImpl` field is the injection point for tests. +// Tests set `anthropic._spawnImpl = mockSpawn` before calling `anthropic.spawn()`. +let _spawnImpl = defaultSpawn; + +export async function* spawn(irRequest, authContext) { + yield* _spawnAndStream(irRequest, authContext, _spawnImpl); +} + +// Test hook: allows tests to inject a mock spawn without importing child_process. +// Set anthropic._spawnImpl = mockFn before calling spawn(); reset after. +export { _spawnImpl }; +export function __setSpawnImpl(impl) { _spawnImpl = impl; } +export function __resetSpawnImpl() { _spawnImpl = defaultSpawn; } + +// ── estimateCost ────────────────────────────────────────────────────────── +// Best-effort token estimation using the chars/4 heuristic. +// Basis: OpenAI Cookbook "How to count tokens" rule-of-thumb: ~4 characters per token +// for English prose. This is approximate; actual tokenization differs per model. +// Reference: https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken +// +// usd: null at D4 — Anthropic per-million-token rates not yet pinned. +// TODO: populate usd once rates are confirmed post-2026-06-15 billing-split audit. +export function estimateCost(request) { + if (!request?.messages) return null; + + let inputChars = 0; + for (const msg of request.messages) { + const text = typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content); + inputChars += text.length; + } + + // Rough output estimate: assume response is ~50% of input length. + const outputCharsEstimate = Math.ceil(inputChars * 0.5); + + return { + inputTokens: Math.ceil(inputChars / 4), + outputTokensEstimate: Math.ceil(outputCharsEstimate / 4), + currency: 'USD', + usd: null, // not pinned at D4 + }; +} + +// ── quotaStatus ─────────────────────────────────────────────────────────── +// Returns null at D4. Anthropic does not expose a programmatic quota endpoint. +// TODO(2026-06-16 one-shot audit per ALIGNMENT.md § One-shot Triggered Audits): +// After the 2026-06-15 Agent SDK Credit billing-split takes effect, check whether +// the Agent SDK Credit pool exposes a balance/usage API endpoint. If it does, +// implement here and update this comment with the endpoint URL + version pin. +export async function quotaStatus(_authContext) { + return null; +} + +// ── healthCheck ─────────────────────────────────────────────────────────── +// Does NOT spawn a real claude -p request (deferred to D5 E2E audit). +// Checks: (1) binary exists, (2) auth artifact exists. +// Ported from OCP server.mjs:363-377 (checkAuth) — simplified to existence +// checks only; no live execution (per D4 spec constraint). +export async function healthCheck({ _binaryExistsFn, _authReadFn } = {}) { + const t0 = Date.now(); + + // 1. Binary check + const binaryExists = _binaryExistsFn ?? _defaultBinaryExists; + if (!binaryExists()) { + return { ok: false, latencyMs: Date.now() - t0, error: 'claude binary not found' }; + } + + // 2. Auth artifact check + const authRead = _authReadFn ?? readAuthArtifact; + const auth = authRead(); + if (!auth?.accessToken) { + return { ok: false, latencyMs: Date.now() - t0, error: 'auth artifact missing' }; + } + + return { ok: true, latencyMs: Date.now() - t0 }; +} + +function _defaultBinaryExists() { + const bin = resolveClaudeBin(); + if (bin !== 'claude') { + // Explicit path given — check directly + return existsSync(bin); + } + // 'claude' from PATH — use which + try { + execSync('which claude', { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }); + return true; + } catch { + return false; + } +} + +// ── Provider export ─────────────────────────────────────────────────────── +// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4 +// contractVersion fold-in per reviewer F3. + +import modelsRegistryRaw from '../../models-registry.json' with { type: 'json' }; + +const _registryModels = (modelsRegistryRaw?.providers?.anthropic?.models ?? []).map(m => m.id); + +const anthropic = { + name: 'anthropic', + displayName: 'Anthropic Claude', + contractVersion: '1.0', + models: _registryModels, + auth: { + type: 'oauth', + storage: 'keychain', + // Portable path via os.homedir() per hygiene requirement + path: join(homedir(), '.claude', '.credentials.json'), + refresh: 'auto', + }, + spawn, + estimateCost, + quotaStatus, + healthCheck, + hints: { + requiresTTY: false, + concurrentSpawnSafe: true, + maxConcurrent: 4, + }, +}; + +export default anthropic; diff --git a/lib/providers/base.mjs b/lib/providers/base.mjs index 8537f7c..c272c00 100644 --- a/lib/providers/base.mjs +++ b/lib/providers/base.mjs @@ -30,6 +30,7 @@ * @typedef {Object} ProviderContractV1 * @property {string} name - unique lowercase key * @property {string} displayName - human-readable name + * @property {'1.0'} contractVersion - must be '1.0' for v1.0 plugins (D4 fold-in per reviewer F3) * @property {string[]} models - model strings this provider serves * @property {ProviderAuth} auth * @property {function} spawn - async (irRequest, authContext) => AsyncIterator @@ -68,6 +69,13 @@ export function validateProvider(p) { errors.push('displayName must be a non-empty string'); } + // contractVersion: required to be exactly '1.0' for v1.0 plugins (D4 fold-in per reviewer F3) + // Per ADR 0002 § Mitigations: "The contract is versioned. v1.0 is the subset in this ADR; + // future additions require ADR amendment plus a contract-version bump." + if (p.contractVersion !== '1.0') { + errors.push(`contractVersion must be '1.0', got ${JSON.stringify(p.contractVersion)}`); + } + if (!Array.isArray(p.models)) { errors.push('models must be an array of strings'); } else if (p.models.some(m => typeof m !== 'string')) { diff --git a/lib/providers/index.mjs b/lib/providers/index.mjs index fa60ef1..35bbdd9 100644 --- a/lib/providers/index.mjs +++ b/lib/providers/index.mjs @@ -9,26 +9,30 @@ * one entry to STATIC_REGISTRY below, (3) README § "Supported Providers", * (4) inclusion ADR per ADR 0006. * - * At D3 (Phase 1 Day 1), the registry is intentionally empty. - * 0 Enabled Providers per ALIGNMENT.md § Provider Inventory. - * Phase 1 Day 2+ will add the anthropic plugin. + * At D4 (Phase 1 Day 2), the anthropic plugin is added to STATIC_REGISTRY + * as a Candidate. It is NOT Enabled by default — a config with + * { enabled: { anthropic: true } } is required, which only happens after D5 + * E2E audit passes per ALIGNMENT.md § Provider Inventory. */ import { validateProvider } from './base.mjs'; +import anthropicDefault from './anthropic.mjs'; + +// Normalize default export pattern +const anthropic = anthropicDefault; // ── Static registry ─────────────────────────────────────────────────────── -// Phase 1 Day 2+ will add imports here, e.g.: -// import * as anthropic from './anthropic.mjs'; -// And push to STATIC_REGISTRY, e.g.: -// STATIC_REGISTRY.push(anthropic.default ?? anthropic); - -const STATIC_REGISTRY = []; +const STATIC_REGISTRY = [ + anthropic, +]; // ── Registry functions ──────────────────────────────────────────────────── /** * Loads and validates providers, filtering by the enabled set in config. + * At D4, the anthropic provider is Candidate only — it will not appear in + * the loaded Map unless config.enabled.anthropic === true (set by D5 after E2E). * * @param {{ enabled?: Record }} [config={}] * @returns {Map} @@ -55,7 +59,7 @@ export function loadProviders(config = {}) { * Returns the first provider in `loadedProviders` whose `models[]` includes * the exact `modelString`. Returns null if no provider is found. * - * This is the naive lookup strategy for D3. Phase 2+ may add prefix/alias + * This is the naive lookup strategy for D4. Phase 2+ may add prefix/alias * matching once models-registry.json is consulted per AGENTS.md SPOT policy. * * @param {Map} loadedProviders @@ -71,9 +75,22 @@ export function getProviderForModel(loadedProviders, modelString) { return null; } +/** + * Looks up a provider by name in the loaded Map. Returns null if not found. + * Useful for tests that need to access a specific provider directly. + * + * @param {Map} loadedProviders + * @param {string} name + * @returns {import('./base.mjs').ProviderContractV1|null} + */ +export function getProviderByName(loadedProviders, name) { + return loadedProviders.get(name) ?? null; +} + /** * Returns all provider names in the static registry (whether enabled or not). * Used by /health and diagnostics. + * At D4: returns ['anthropic'] (the candidate roster). * * @returns {string[]} */ diff --git a/models-registry.json b/models-registry.json index 3e306a0..de0e24d 100644 --- a/models-registry.json +++ b/models-registry.json @@ -1,5 +1,37 @@ { "version": "0.1.0-bootstrap", - "comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding ships zero Enabled Providers per ALIGNMENT.md § Provider Inventory, so 'providers' is empty. Each Phase audit that transitions a Candidate to Enabled populates its provider's entry here. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.", - "providers": {} + "comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding shipped zero Enabled Providers per ALIGNMENT.md § Provider Inventory. D4 populates providers.anthropic as Candidate; D5 transitions to Enabled pending E2E audit. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.", + "providers": { + "anthropic": { + "displayName": "Anthropic Claude", + "tier": "D", + "candidate": true, + "models": [ + { + "id": "claude-opus-4-7", + "displayName": "Claude Opus 4.7", + "contextWindow": 200000, + "deprecated": false + }, + { + "id": "claude-sonnet-4-6", + "displayName": "Claude Sonnet 4.6", + "contextWindow": 200000, + "deprecated": false + }, + { + "id": "claude-haiku-4-5", + "displayName": "Claude Haiku 4.5", + "contextWindow": 200000, + "deprecated": false + } + ], + "aliases": { + "claude": "claude-sonnet-4-6", + "sonnet": "claude-sonnet-4-6", + "opus": "claude-opus-4-7", + "haiku": "claude-haiku-4-5" + } + } + } } diff --git a/test-features.mjs b/test-features.mjs index 46d2fbe..88eecdc 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1,15 +1,18 @@ /** - * test-features.mjs — OLP D3 test suite + * test-features.mjs — OLP D4 test suite (extends D3) * * Uses Node's built-in node:test runner. No external dependencies. * Run: node test-features.mjs (or: npm test) * * Authority: ADR 0002 (provider contract), ADR 0003 (IR v1.0) + * D4 adds: Anthropic plugin conformance, IR translation, mock-spawn behaviour. */ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { request as httpRequest } from 'node:http'; +import { EventEmitter } from 'node:events'; +import { homedir } from 'node:os'; // ── Modules under test ──────────────────────────────────────────────────── @@ -22,7 +25,19 @@ import { SSE_DONE, } from './lib/ir/ir-to-openai.mjs'; import { validateProvider, ProviderError, withTimeout } from './lib/providers/base.mjs'; -import { loadProviders, getProviderForModel, listAllProviderNames } from './lib/providers/index.mjs'; +import { loadProviders, getProviderForModel, getProviderByName, listAllProviderNames } from './lib/providers/index.mjs'; +import anthropic, { + irToAnthropic, + anthropicChunkToIR, + anthropicStopToIR, + readAuthArtifact, + estimateCost as anthropicEstimateCost, + quotaStatus as anthropicQuotaStatus, + healthCheck as anthropicHealthCheck, + __setSpawnImpl, + __resetSpawnImpl, +} from './lib/providers/anthropic.mjs'; +import modelsRegistry from './models-registry.json' with { type: 'json' }; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -37,11 +52,12 @@ function makeIR(overrides = {}) { }; } -/** Minimal valid provider stub that satisfies the v1.0 contract */ +/** Minimal valid provider stub that satisfies the v1.0 contract (including D4 contractVersion) */ function makeProvider(overrides = {}) { return { name: 'stub', displayName: 'Stub Provider', + contractVersion: '1.0', models: ['stub-model-v1'], auth: { type: 'none', storage: 'none', path: '', refresh: null }, spawn: async function* () { yield { type: 'stop', finish_reason: 'stop' }; }, @@ -459,7 +475,12 @@ describe('Provider contract validation', () => { // ── Suite 5: Plugin registry ────────────────────────────────────────────── describe('Plugin registry', () => { - it('empty STATIC_REGISTRY → loadProviders returns empty Map', () => { + it('STATIC_REGISTRY has 1 entry (anthropic candidate) at D4', () => { + // D4: anthropic is in STATIC_REGISTRY but default config has enabled:false + assert.equal(listAllProviderNames().length, 1); + }); + + it('loadProviders with empty config → empty Map (anthropic not enabled)', () => { const m = loadProviders({}); assert.equal(m.size, 0); }); @@ -469,8 +490,8 @@ describe('Plugin registry', () => { assert.equal(m.size, 0); }); - it('listAllProviderNames returns empty array at D3', () => { - assert.deepEqual(listAllProviderNames(), []); + it('listAllProviderNames returns [anthropic] at D4', () => { + assert.deepEqual(listAllProviderNames(), ['anthropic']); }); it('getProviderForModel returns null when no providers loaded', () => { @@ -493,9 +514,455 @@ describe('Plugin registry', () => { const m = new Map([['alpha', p]]); assert.equal(getProviderForModel(m, 'beta-v1'), null); }); + + it('getProviderByName returns null for empty loaded map', () => { + const m = new Map(); + assert.equal(getProviderByName(m, 'anthropic'), null); + }); + + it('getProviderByName returns provider when found', () => { + const p = makeProvider({ name: 'alpha', models: ['alpha-v1'] }); + const m = new Map([['alpha', p]]); + assert.ok(getProviderByName(m, 'alpha') !== null); + assert.equal(getProviderByName(m, 'alpha').name, 'alpha'); + }); + + it('anthropic provider passes contract validation (STATIC_REGISTRY entry)', () => { + // Even though anthropic is Candidate (not enabled by default), it must + // pass contract validation at module load — loadProviders() validates all + // registry entries regardless of enabled flag. + const { valid, errors } = validateProvider(anthropic); + assert.equal(valid, true, `Validation errors: ${errors.join('; ')}`); + }); }); -// ── Suite 6: HTTP integration tests ────────────────────────────────────── +// ── Suite 6: Anthropic plugin (D4) ─────────────────────────────────────── +// +// All tests in this suite are UNIT tests. No real `claude` binary is invoked. +// Mock spawn is injected via __setSpawnImpl / __resetSpawnImpl. +// Tests verify: contract conformance, contractVersion enforcement, registry +// consistency, IR translation, mock-spawn stream, healthCheck, estimateCost. + +/** + * Creates a fake spawn that emits canned stdout chunks then exits cleanly. + * Returns a fake ChildProcess-like EventEmitter with stdin, stdout, stderr. + * @param {string[]} stdoutChunks — text chunks emitted in order + * @param {number} [exitCode=0] + */ +function makeMockSpawn(stdoutChunks, exitCode = 0) { + return function mockSpawnImpl(_bin, _args, _opts) { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + write: () => {}, + end: () => { + // Emit stdout chunks and close asynchronously + setImmediate(async () => { + for (const chunk of stdoutChunks) { + proc.stdout.emit('data', Buffer.from(chunk)); + } + proc.stdout.emit('end'); + proc.stderr.emit('end'); + proc.emit('close', exitCode, null); + }); + }, + }; + proc.killed = false; + proc.kill = () => {}; + return proc; + }; +} + +describe('Anthropic plugin (D4)', () => { + + // ── Test 1: Contract conformance ────────────────────────────────────── + it('anthropic module satisfies validateProvider() — all 10 fields present', () => { + const { valid, errors } = validateProvider(anthropic); + assert.equal(valid, true, `Validation errors: ${errors.join('; ')}`); + // Verify all 10 contract fields explicitly + assert.ok('name' in anthropic, 'missing: name'); + assert.ok('displayName' in anthropic, 'missing: displayName'); + assert.ok('contractVersion' in anthropic, 'missing: contractVersion'); + assert.ok('models' in anthropic, 'missing: models'); + assert.ok('auth' in anthropic, 'missing: auth'); + assert.ok(typeof anthropic.spawn === 'function', 'missing: spawn'); + assert.ok(typeof anthropic.estimateCost === 'function', 'missing: estimateCost'); + assert.ok(typeof anthropic.quotaStatus === 'function', 'missing: quotaStatus'); + assert.ok(typeof anthropic.healthCheck === 'function', 'missing: healthCheck'); + assert.ok('hints' in anthropic, 'missing: hints'); + }); + + // ── Test 2: contractVersion enforced ───────────────────────────────── + it('validateProvider rejects provider missing contractVersion', () => { + const p = makeProvider(); + delete p.contractVersion; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('contractVersion'))); + }); + + it('validateProvider rejects contractVersion: "0.9"', () => { + const p = makeProvider({ contractVersion: '0.9' }); + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('contractVersion'))); + }); + + it('validateProvider accepts contractVersion: "1.0"', () => { + const p = makeProvider({ contractVersion: '1.0' }); + const r = validateProvider(p); + assert.equal(r.valid, true); + }); + + it('validateProvider rejects contractVersion: undefined', () => { + const p = makeProvider({ contractVersion: undefined }); + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('contractVersion'))); + }); + + // ── Test 3: models match registry ──────────────────────────────────── + it('anthropic.models matches models-registry.json providers.anthropic.models', () => { + const registryIds = modelsRegistry.providers.anthropic.models.map(m => m.id); + assert.deepEqual(anthropic.models, registryIds); + }); + + it('anthropic.models contains the three expected model IDs', () => { + assert.ok(anthropic.models.includes('claude-opus-4-7')); + assert.ok(anthropic.models.includes('claude-sonnet-4-6')); + assert.ok(anthropic.models.includes('claude-haiku-4-5')); + assert.equal(anthropic.models.length, 3); + }); + + // ── Test 4: getProviderForModel finds anthropic for each model ──────── + it('getProviderForModel finds anthropic for claude-sonnet-4-6 when enabled', () => { + const loaded = new Map([['anthropic', anthropic]]); + const result = getProviderForModel(loaded, 'claude-sonnet-4-6'); + assert.ok(result !== null); + assert.equal(result.name, 'anthropic'); + }); + + it('getProviderForModel finds anthropic for claude-opus-4-7 when enabled', () => { + const loaded = new Map([['anthropic', anthropic]]); + const result = getProviderForModel(loaded, 'claude-opus-4-7'); + assert.ok(result !== null); + assert.equal(result.name, 'anthropic'); + }); + + it('getProviderForModel finds anthropic for claude-haiku-4-5 when enabled', () => { + const loaded = new Map([['anthropic', anthropic]]); + const result = getProviderForModel(loaded, 'claude-haiku-4-5'); + assert.ok(result !== null); + assert.equal(result.name, 'anthropic'); + }); + + // ── Test 5: irToAnthropic translation ──────────────────────────────── + it('irToAnthropic: user message → plain text', () => { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'Hello world' }], + }); + const prompt = irToAnthropic(ir); + assert.ok(typeof prompt === 'string'); + assert.ok(prompt.includes('Hello world')); + assert.ok(!prompt.includes('[System]')); + assert.ok(!prompt.includes('[Assistant]')); + }); + + it('irToAnthropic: system + user → system annotation + user text', () => { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [ + { role: 'system', content: 'You are a helper.' }, + { role: 'user', content: 'What is 2+2?' }, + ], + }); + const prompt = irToAnthropic(ir); + assert.ok(prompt.includes('[System] You are a helper.')); + assert.ok(prompt.includes('What is 2+2?')); + }); + + it('irToAnthropic: assistant turn → [Assistant] annotation', () => { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello!' }, + { role: 'user', content: 'Bye' }, + ], + }); + const prompt = irToAnthropic(ir); + assert.ok(prompt.includes('[Assistant] Hello!')); + }); + + it('irToAnthropic: response_format json_object injects system prompt', () => { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'Give JSON' }], + response_format: { type: 'json_object' }, + }); + const prompt = irToAnthropic(ir); + assert.ok(prompt.includes('Reply with valid JSON only')); + }); + + it('irToAnthropic: tool result turn → [Tool Result] annotation', () => { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [ + { role: 'user', content: 'Search for something' }, + { role: 'tool', content: '{"results": []}', name: 'search' }, + ], + }); + const prompt = irToAnthropic(ir); + assert.ok(prompt.includes('[Tool Result')); + assert.ok(prompt.includes('search')); + }); + + it('irToAnthropic: array content is JSON-stringified', () => { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }); + const prompt = irToAnthropic(ir); + assert.ok(typeof prompt === 'string'); + assert.ok(prompt.includes('text')); + }); + + // ── Test 6: anthropicChunkToIR and anthropicStopToIR ───────────────── + it('anthropicChunkToIR: produces delta chunk with content', () => { + const chunk = anthropicChunkToIR('Hello ', false); + assert.equal(chunk.type, 'delta'); + assert.equal(chunk.content, 'Hello '); + assert.ok(!('role' in chunk)); + }); + + it('anthropicChunkToIR: first chunk includes role=assistant', () => { + const chunk = anthropicChunkToIR('Hello', true); + assert.equal(chunk.type, 'delta'); + assert.equal(chunk.role, 'assistant'); + assert.equal(chunk.content, 'Hello'); + }); + + it('anthropicStopToIR: produces stop chunk with finish_reason', () => { + const chunk = anthropicStopToIR('stop'); + assert.equal(chunk.type, 'stop'); + assert.equal(chunk.finish_reason, 'stop'); + }); + + // ── Test 7: mock spawn — AsyncIterator yields correct IR chunks ─────── + it('spawn with mock: yields delta chunks then stop chunk', async () => { + const fakeSpawn = makeMockSpawn(['Hello', ' world']); + __setSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + stream: true, + messages: [{ role: 'user', content: 'Hi' }], + }); + const authCtx = { accessToken: '' }; + const chunks = []; + for await (const chunk of anthropic.spawn(ir, authCtx)) { + chunks.push(chunk); + } + // Should have 2 delta chunks + 1 stop chunk + const deltas = chunks.filter(c => c.type === 'delta'); + const stops = chunks.filter(c => c.type === 'stop'); + assert.ok(deltas.length >= 1, `Expected at least 1 delta, got ${deltas.length}`); + assert.equal(stops.length, 1, `Expected 1 stop, got ${stops.length}`); + const allContent = deltas.map(c => c.content).join(''); + assert.equal(allContent, 'Hello world'); + } finally { + __resetSpawnImpl(); + } + }); + + it('spawn with mock: first delta chunk has role=assistant', async () => { + const fakeSpawn = makeMockSpawn(['Test output']); + __setSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + stream: true, + messages: [{ role: 'user', content: 'Hello' }], + }); + const authCtx = { accessToken: '' }; + const chunks = []; + for await (const chunk of anthropic.spawn(ir, authCtx)) { + chunks.push(chunk); + } + const firstDelta = chunks.find(c => c.type === 'delta'); + assert.ok(firstDelta, 'No delta chunk found'); + assert.equal(firstDelta.role, 'assistant'); + } finally { + __resetSpawnImpl(); + } + }); + + it('spawn with mock: non-zero exit code throws ProviderError', async () => { + const fakeSpawn = makeMockSpawn([], 1); + __setSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + stream: true, + messages: [{ role: 'user', content: 'Hi' }], + }); + const authCtx = { accessToken: '' }; + let caught = null; + try { + // eslint-disable-next-line no-unused-vars + for await (const _chunk of anthropic.spawn(ir, authCtx)) { + // drain + } + } catch (e) { + caught = e; + } + assert.ok(caught instanceof ProviderError, `Expected ProviderError, got ${caught?.constructor?.name}`); + assert.equal(caught.code, 'SPAWN_FAILED'); + } finally { + __resetSpawnImpl(); + } + }); + + it('spawn throws ProviderError(AUTH_MISSING) when no auth context and no env/file', async () => { + const fakeSpawn = makeMockSpawn(['output']); + __setSpawnImpl(fakeSpawn); + // Temporarily clear the env var if set + const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + try { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + stream: false, + messages: [{ role: 'user', content: 'Hi' }], + }); + let caught = null; + try { + // Pass null as authContext to force re-read + for await (const _chunk of anthropic.spawn(ir, null)) { // eslint-disable-line no-unused-vars + // may or may not throw depending on whether credentials exist on this machine + } + } catch (e) { + caught = e; + } + // If credentials.json or keychain exists on the test machine, this won't throw. + // We only assert that IF it throws, it's AUTH_MISSING. + if (caught !== null) { + assert.ok(caught instanceof ProviderError, `Expected ProviderError, got ${caught?.constructor?.name}`); + assert.equal(caught.code, 'AUTH_MISSING'); + } + } finally { + if (savedToken !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken; + __resetSpawnImpl(); + } + }); + + // ── Test 8: healthCheck — binary not found ──────────────────────────── + it('healthCheck returns {ok: false, error: "claude binary not found"} when binary absent', async () => { + const result = await anthropicHealthCheck({ + _binaryExistsFn: () => false, + _authReadFn: () => ({ accessToken: '' }), + }); + assert.equal(result.ok, false); + assert.equal(result.error, 'claude binary not found'); + assert.ok(typeof result.latencyMs === 'number'); + }); + + // ── Test 9: healthCheck — auth artifact missing ─────────────────────── + it('healthCheck returns {ok: false, error: "auth artifact missing"} when auth missing', async () => { + const result = await anthropicHealthCheck({ + _binaryExistsFn: () => true, + _authReadFn: () => null, + }); + assert.equal(result.ok, false); + assert.equal(result.error, 'auth artifact missing'); + assert.ok(typeof result.latencyMs === 'number'); + }); + + it('healthCheck returns {ok: true} when binary and auth present', async () => { + const result = await anthropicHealthCheck({ + _binaryExistsFn: () => true, + _authReadFn: () => ({ accessToken: '' }), + }); + assert.equal(result.ok, true); + assert.ok(typeof result.latencyMs === 'number'); + }); + + // ── Test 10: estimateCost shape ─────────────────────────────────────── + it('estimateCost returns object with four fields for a valid request', () => { + const request = makeIR({ + model: 'claude-sonnet-4-6', + messages: [ + { role: 'system', content: 'You are a helper.' }, + { role: 'user', content: 'Count to ten.' }, + ], + }); + const result = anthropicEstimateCost(request); + assert.ok(result !== null, 'estimateCost returned null'); + assert.ok('inputTokens' in result, 'missing inputTokens'); + assert.ok('outputTokensEstimate' in result, 'missing outputTokensEstimate'); + assert.ok('currency' in result, 'missing currency'); + assert.ok('usd' in result, 'missing usd'); + assert.equal(result.currency, 'USD'); + assert.equal(result.usd, null); // not pinned at D4 + assert.ok(result.inputTokens > 0, 'inputTokens should be > 0'); + assert.ok(result.outputTokensEstimate >= 0, 'outputTokensEstimate should be >= 0'); + }); + + it('estimateCost returns null for null/missing request', () => { + assert.equal(anthropicEstimateCost(null), null); + assert.equal(anthropicEstimateCost({}), null); + }); + + // ── Test 11: quotaStatus ────────────────────────────────────────────── + it('quotaStatus returns null at D4', async () => { + const result = await anthropicQuotaStatus({}); + assert.equal(result, null); + }); + + // ── Test 12: auth object shape ──────────────────────────────────────── + it('anthropic.auth has correct shape', () => { + assert.equal(typeof anthropic.auth.type, 'string'); + assert.equal(typeof anthropic.auth.storage, 'string'); + assert.equal(typeof anthropic.auth.path, 'string'); + assert.ok(anthropic.auth.path.includes('.claude'), 'auth.path should reference .claude directory'); + // Portability check: path must start with the runtime homedir() value (set at module load) + // rather than a hardcoded literal. Since path.join(homedir(), ...) produces the home dir + // as a prefix, we verify it matches what homedir() returns at test time. + assert.ok( + anthropic.auth.path.startsWith(homedir()), + `auth.path "${anthropic.auth.path}" should start with homedir() "${homedir()}"`, + ); + assert.ok(typeof anthropic.auth.refresh === 'string' || anthropic.auth.refresh === null); + }); + + // ── Test 13: hints shape ────────────────────────────────────────────── + it('anthropic.hints has correct shape', () => { + assert.equal(typeof anthropic.hints.requiresTTY, 'boolean'); + assert.equal(typeof anthropic.hints.concurrentSpawnSafe, 'boolean'); + assert.ok(Number.isInteger(anthropic.hints.maxConcurrent) && anthropic.hints.maxConcurrent > 0); + assert.equal(anthropic.hints.requiresTTY, false); + }); + + // ── Test 14: loadProviders with anthropic enabled ───────────────────── + it('loadProviders with {enabled: {anthropic: true}} returns Map of size 1', () => { + const loaded = loadProviders({ enabled: { anthropic: true } }); + assert.equal(loaded.size, 1); + assert.ok(loaded.has('anthropic')); + }); + + it('anthropic loaded via loadProviders passes contract and has correct models', () => { + const loaded = loadProviders({ enabled: { anthropic: true } }); + const p = loaded.get('anthropic'); + const { valid, errors } = validateProvider(p); + assert.equal(valid, true, `Contract errors: ${errors.join('; ')}`); + assert.ok(p.models.includes('claude-sonnet-4-6')); + }); + +}); + +// ── Suite 7: HTTP integration tests ────────────────────────────────────── describe('HTTP integration', () => { let serverInstance;