mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
co-authored by
Claude Opus 4.7 (noreply@anthropic.com)
parent
e2e67de23a
commit
c175e8994c
+474
-7
@@ -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: '<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', () => {
|
||||
let serverInstance;
|
||||
|
||||
Reference in New Issue
Block a user