feat(phase-1): land Anthropic provider plugin (D4)

Phase 1 Day 2. Anthropic provider plugin code lands, plus contractVersion
field added across base.mjs and validated strictly. Anthropic stays
CANDIDATE per ALIGNMENT.md Provider Inventory — D5 flips to Enabled after
the real spawn E2E audit passes. POST /v1/chat/completions claude-* still
returns 503 until then.

Files:
  NEW:  lib/providers/anthropic.mjs (445 lines)
  MOD:  lib/providers/base.mjs (+8 lines — contractVersion enforcement)
  MOD:  lib/providers/index.mjs (+37 lines — STATIC_REGISTRY adds anthropic
        + getProviderByName helper)
  MOD:  models-registry.json — populates providers.anthropic with 3 models
        opus-4-7 / sonnet-4-6 / haiku-4-5, alias map, candidate marker
  MOD:  test-features.mjs (+481 lines — Suite 6: 37 new tests covering
        contract conformance, contractVersion enforcement, IR translation,
        mock-spawn behaviour, healthCheck, estimateCost)

Authority citations (all verified by independent reviewer against actual
OCP byte offsets):
  Spawn pattern: OCP server.mjs:542 stdio shape, port verbatim.
  CLI args: OCP server.mjs:384-414 buildCliArgs pattern — -p, --model X,
    --output-format text, --no-session-persistence (session-resume and
    permissions branches stripped per OLP no-state architecture).
  stdin write: OCP server.mjs:586-587 verbatim.
  Stdout text handling: OCP server.mjs:735-748 raw d.toString per chunk
    no JSON envelope, matches --output-format text.
  Auth chain: OCP server.mjs:864-888 (env CLAUDE_CODE_OAUTH_TOKEN ->
    ~/.claude/.credentials.json -> macOS keychain with both label formats
    "claude-code-credentials" and "Claude Code-credentials") ported in
    same priority order. One delta vs OCP: OLP guards keychain branch on
    process.platform === darwin, OCP relies on try/catch on Linux. Both
    behave identically; OLP avoids an unnecessary shell-out.
  Env cleanup: OCP server.mjs:530-534 — delete CLAUDECODE, ANTHROPIC_
    API_KEY, ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN. CLAUDECODE
    clobbering pitfall inherited per memory.

Architectural decisions:
  1. Anthropic stays Candidate at D4. STATIC_REGISTRY.length === 1 but
     loadProviders({}) returns empty Map. Suite 7 HTTP integration tests
     continue to verify 503 with no_enabled_provider for any claude-*
     model. D5 changes the config default to enable: { anthropic: true }
     and adds the real E2E spawn test.
  2. contractVersion === 1.0 strictly enforced (F3 fold-in from D3
     review). validateProvider in base.mjs rejects providers missing or
     having any other version string. Suite 6 includes 4 tests covering
     missing / 0.9 / 1.0 / undefined cases.
  3. quotaStatus returns null at D4 with a TODO comment pointing at the
     ALIGNMENT.md 2026-06-16 one-shot audit. Anthropic Agent SDK Credit
     pool balance API has not been pinned; verification scheduled for
     2026-06-16 per OLP one-shot audits.
  4. estimateCost returns shape but usd: null. Per-million-token rates
     not pinned at D4. Lands when models-registry.json gains a pricing
     field in a later phase.
  5. Lossy translations explicitly documented in anthropic.mjs file
     header per ADR 0003 § Lossy-translation documentation requirement.
     Includes response_format json_object (system-prompt augmented),
     top_p (no --top-p flag), tool_choice required (no flag), and
     request-level tools[] + assistant tool_calls + tool_call_id
     (text-in/text-out CLI cannot consume structured tool wire format).
     The tools[] documentation gap was a reviewer non-blocking finding;
     folded in this commit.

Mocking discipline: no real claude -p spawn in any D4 test. spawn-path
coverage uses __setSpawnImpl injection of fake child_process. No real
OAuth tokens or API keys in fixtures — all use placeholder strings
fake-oauth-token / fake-token. Auth path computed via
path.join(homedir(), .claude, .credentials.json), no hardcoded
/Users/<name> literal.

Reviewer chain (Iron Rule 10):
  Implementer: sonnet (general-purpose).
  Fresh-context reviewer: opus (ecc:code-reviewer). Verdict
    APPROVE_WITH_MINOR.
  Reviewer ran npm test (98/98 pass) and verified all five OCP citations
    at the actual byte offsets in /Users/taodeng/ocp/server.mjs. All
    citations confirmed accurate.

Reviewer non-blocking findings:
  1. tools[] and tool_calls lossy-translation undocumented — FOLDED IN
     this commit (anthropic.mjs header rewritten with full lossy list).
  2. with type json import attribute Node 20 compat — DEFERRED to CI
     verification. The syntax is stable on Node 20.10+ and the CI
     setup-node@v4 with node-version 20 resolves to latest 20.x. If
     CI Node 20 leg fails, mitigation is bump engines.node to >=20.10
     or swap both import-attribute lines for readFileSync + JSON.parse.
  3. CLI_NOT_FOUND error code declared but never thrown — DEFERRED to
     a future commit. Pure cosmetic; could distinguish ENOENT from
     generic spawn errors but no functional impact.

Test count: 61 -> 98 (+37 D4 tests).

Verification:
  node --check on all touched files: clean.
  npm test on Node 25.8.0: 98/98 pass in 209ms.
  Reviewer-run npm test independently: 98/98 pass.
  loadProviders({}) returns empty Map (verified by orchestrator and
    reviewer): Anthropic Candidate gate holds.
  hygiene grep: no personal names, no /Users/<name>/ literals, no real
    OAuth tokens or API keys.

Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
This commit is contained in:
2026-05-23 17:59:48 +10:00
co-authored by Claude Opus 4.7 (noreply@anthropic.com)
parent e2e67de23a
commit c175e8994c
5 changed files with 996 additions and 19 deletions
+453
View File
@@ -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] <text>", assistant → "[Assistant] <text>",
// 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: <text> }
// 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<IRResponseChunk> 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<ResponseChunk>
//
// 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;
+8
View File
@@ -30,6 +30,7 @@
* @typedef {Object} ProviderContractV1 * @typedef {Object} ProviderContractV1
* @property {string} name - unique lowercase key * @property {string} name - unique lowercase key
* @property {string} displayName - human-readable name * @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 {string[]} models - model strings this provider serves
* @property {ProviderAuth} auth * @property {ProviderAuth} auth
* @property {function} spawn - async (irRequest, authContext) => AsyncIterator<IRResponseChunk> * @property {function} spawn - async (irRequest, authContext) => AsyncIterator<IRResponseChunk>
@@ -68,6 +69,13 @@ export function validateProvider(p) {
errors.push('displayName must be a non-empty string'); 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)) { if (!Array.isArray(p.models)) {
errors.push('models must be an array of strings'); errors.push('models must be an array of strings');
} else if (p.models.some(m => typeof m !== 'string')) { } else if (p.models.some(m => typeof m !== 'string')) {
+27 -10
View File
@@ -9,26 +9,30 @@
* one entry to STATIC_REGISTRY below, (3) README § "Supported Providers", * one entry to STATIC_REGISTRY below, (3) README § "Supported Providers",
* (4) inclusion ADR per ADR 0006. * (4) inclusion ADR per ADR 0006.
* *
* At D3 (Phase 1 Day 1), the registry is intentionally empty. * At D4 (Phase 1 Day 2), the anthropic plugin is added to STATIC_REGISTRY
* 0 Enabled Providers per ALIGNMENT.md § Provider Inventory. * as a Candidate. It is NOT Enabled by default — a config with
* Phase 1 Day 2+ will add the anthropic plugin. * { enabled: { anthropic: true } } is required, which only happens after D5
* E2E audit passes per ALIGNMENT.md § Provider Inventory.
*/ */
import { validateProvider } from './base.mjs'; import { validateProvider } from './base.mjs';
import anthropicDefault from './anthropic.mjs';
// Normalize default export pattern
const anthropic = anthropicDefault;
// ── Static registry ─────────────────────────────────────────────────────── // ── Static registry ───────────────────────────────────────────────────────
// Phase 1 Day 2+ will add imports here, e.g.: const STATIC_REGISTRY = [
// import * as anthropic from './anthropic.mjs'; anthropic,
// And push to STATIC_REGISTRY, e.g.: ];
// STATIC_REGISTRY.push(anthropic.default ?? anthropic);
const STATIC_REGISTRY = [];
// ── Registry functions ──────────────────────────────────────────────────── // ── Registry functions ────────────────────────────────────────────────────
/** /**
* Loads and validates providers, filtering by the enabled set in config. * 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<string,boolean> }} [config={}] * @param {{ enabled?: Record<string,boolean> }} [config={}]
* @returns {Map<string, import('./base.mjs').ProviderContractV1>} * @returns {Map<string, import('./base.mjs').ProviderContractV1>}
@@ -55,7 +59,7 @@ export function loadProviders(config = {}) {
* Returns the first provider in `loadedProviders` whose `models[]` includes * Returns the first provider in `loadedProviders` whose `models[]` includes
* the exact `modelString`. Returns null if no provider is found. * 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. * matching once models-registry.json is consulted per AGENTS.md SPOT policy.
* *
* @param {Map<string, import('./base.mjs').ProviderContractV1>} loadedProviders * @param {Map<string, import('./base.mjs').ProviderContractV1>} loadedProviders
@@ -71,9 +75,22 @@ export function getProviderForModel(loadedProviders, modelString) {
return null; 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<string, import('./base.mjs').ProviderContractV1>} 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). * Returns all provider names in the static registry (whether enabled or not).
* Used by /health and diagnostics. * Used by /health and diagnostics.
* At D4: returns ['anthropic'] (the candidate roster).
* *
* @returns {string[]} * @returns {string[]}
*/ */
+34 -2
View File
@@ -1,5 +1,37 @@
{ {
"version": "0.1.0-bootstrap", "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.", "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": {} "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"
}
}
}
} }
+474 -7
View File
@@ -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. * Uses Node's built-in node:test runner. No external dependencies.
* Run: node test-features.mjs (or: npm test) * Run: node test-features.mjs (or: npm test)
* *
* Authority: ADR 0002 (provider contract), ADR 0003 (IR v1.0) * 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 { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { request as httpRequest } from 'node:http'; import { request as httpRequest } from 'node:http';
import { EventEmitter } from 'node:events';
import { homedir } from 'node:os';
// ── Modules under test ──────────────────────────────────────────────────── // ── Modules under test ────────────────────────────────────────────────────
@@ -22,7 +25,19 @@ import {
SSE_DONE, SSE_DONE,
} from './lib/ir/ir-to-openai.mjs'; } from './lib/ir/ir-to-openai.mjs';
import { validateProvider, ProviderError, withTimeout } from './lib/providers/base.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 ─────────────────────────────────────────────────────────────── // ── 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 = {}) { function makeProvider(overrides = {}) {
return { return {
name: 'stub', name: 'stub',
displayName: 'Stub Provider', displayName: 'Stub Provider',
contractVersion: '1.0',
models: ['stub-model-v1'], models: ['stub-model-v1'],
auth: { type: 'none', storage: 'none', path: '', refresh: null }, auth: { type: 'none', storage: 'none', path: '', refresh: null },
spawn: async function* () { yield { type: 'stop', finish_reason: 'stop' }; }, spawn: async function* () { yield { type: 'stop', finish_reason: 'stop' }; },
@@ -459,7 +475,12 @@ describe('Provider contract validation', () => {
// ── Suite 5: Plugin registry ────────────────────────────────────────────── // ── Suite 5: Plugin registry ──────────────────────────────────────────────
describe('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({}); const m = loadProviders({});
assert.equal(m.size, 0); assert.equal(m.size, 0);
}); });
@@ -469,8 +490,8 @@ describe('Plugin registry', () => {
assert.equal(m.size, 0); assert.equal(m.size, 0);
}); });
it('listAllProviderNames returns empty array at D3', () => { it('listAllProviderNames returns [anthropic] at D4', () => {
assert.deepEqual(listAllProviderNames(), []); assert.deepEqual(listAllProviderNames(), ['anthropic']);
}); });
it('getProviderForModel returns null when no providers loaded', () => { it('getProviderForModel returns null when no providers loaded', () => {
@@ -493,9 +514,455 @@ describe('Plugin registry', () => {
const m = new Map([['alpha', p]]); const m = new Map([['alpha', p]]);
assert.equal(getProviderForModel(m, 'beta-v1'), null); 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);
}); });
// ── Suite 6: HTTP integration tests ────────────────────────────────────── 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: 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: '<fake-oauth-token>' };
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: '<fake-oauth-token>' };
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: '<fake-oauth-token>' };
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: '<fake-token>' }),
});
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: '<fake-token>' }),
});
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', () => { describe('HTTP integration', () => {
let serverInstance; let serverInstance;