test(byok): add 35 unit tests + update docs to as-built status

Three new test suites covering the critical BYOK paths:

Pricing (14 tests): estimateCost for known/unknown models, scaling,
formatCost edge cases, coverage check for all model IDs.

ByokBackend (10 tests): tier identification, resolver behavior,
provider dispatch, parameter passthrough, onUsage callback, error
paths (no key, unregistered provider), invalidateAvailability.

ByokVault (11 tests): encryption at rest verification, decryption
round-trip, auto-default for first key, promoting default demotes
previous, getForProvider logic, listMeta excludes apiKey, soft
delete, recordUsage accumulation, cross-provider isolation.

Updates docs/architecture/BYOK_PLAN.md with as-built status —
phase table with commit references, deviations from original plan
(no server-proxy fallback, no sensitive opt-in UI, no per-task
provider override yet), test coverage matrix, troubleshooting
guide, v2 follow-ups.

Provider adapters remain unit-untested (need fetch mocking + SSE
parsing) — smoke tests only.

Total: 35/35 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 15:23:03 +02:00
parent 7c6567a815
commit e4f0a410d1
4 changed files with 614 additions and 23 deletions

View file

@ -0,0 +1,226 @@
import { describe, it, expect, vi } from 'vitest';
import { ByokBackend, type ByokKeyResolver } from './byok';
import type { ByokProvider, ByokProviderId } from './byok-providers/types';
import type { GenerateResult } from '../types';
function makeProvider(id: ByokProviderId, call?: ByokProvider['call']): ByokProvider {
return {
id,
displayName: id,
defaultModel: `${id}-default`,
availableModels: [`${id}-default`, `${id}-big`],
call:
call ??
(async () => ({
content: `response from ${id}`,
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
latencyMs: 0,
})),
};
}
describe('ByokBackend', () => {
it('has tier === "byok"', () => {
const backend = new ByokBackend({
resolver: async () => null,
providers: [makeProvider('openai')],
});
expect(backend.tier).toBe('byok');
});
it('isReady returns false when no key resolves', async () => {
const backend = new ByokBackend({
resolver: async () => null,
providers: [makeProvider('openai')],
});
expect(await backend.isReady()).toBe(false);
});
it('isReady returns true when a key resolves', async () => {
const resolver: ByokKeyResolver = async () => ({
provider: 'openai',
apiKey: 'sk-test',
model: 'gpt-4o',
});
const backend = new ByokBackend({
resolver,
providers: [makeProvider('openai')],
});
expect(await backend.isReady()).toBe(true);
});
it('generate() dispatches to correct provider', async () => {
const openaiCall = vi.fn(async () => ({
content: 'openai hi',
usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
latencyMs: 0,
}));
const anthropicCall = vi.fn(async () => ({
content: 'anthropic hi',
usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
latencyMs: 0,
}));
const resolver: ByokKeyResolver = async () => ({
provider: 'anthropic',
apiKey: 'sk-ant',
model: 'claude-opus-4-6',
});
const backend = new ByokBackend({
resolver,
providers: [makeProvider('openai', openaiCall), makeProvider('anthropic', anthropicCall)],
});
const result = await backend.generate({
taskName: 'test',
contentClass: 'personal',
messages: [{ role: 'user', content: 'hi' }],
});
expect(anthropicCall).toHaveBeenCalledOnce();
expect(openaiCall).not.toHaveBeenCalled();
expect(result.content).toBe('anthropic hi');
});
it('generate() passes apiKey, model, messages to provider', async () => {
const call = vi.fn(async () => ({
content: '',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
latencyMs: 0,
}));
const resolver: ByokKeyResolver = async () => ({
provider: 'openai',
apiKey: 'sk-test-key',
model: 'gpt-4o',
});
const backend = new ByokBackend({
resolver,
providers: [makeProvider('openai', call)],
});
await backend.generate({
taskName: 'test',
contentClass: 'personal',
messages: [{ role: 'user', content: 'hello' }],
temperature: 0.5,
maxTokens: 200,
});
expect(call).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'sk-test-key',
model: 'gpt-4o',
temperature: 0.5,
maxTokens: 200,
messages: [{ role: 'user', content: 'hello' }],
})
);
});
it('generate() throws when no key configured', async () => {
const backend = new ByokBackend({
resolver: async () => null,
providers: [makeProvider('openai')],
});
await expect(
backend.generate({
taskName: 'test',
contentClass: 'personal',
messages: [{ role: 'user', content: 'hi' }],
})
).rejects.toThrow(/Kein BYOK-Schluessel/);
});
it('generate() throws when provider not registered', async () => {
const resolver: ByokKeyResolver = async () => ({
provider: 'gemini' as ByokProviderId,
apiKey: 'k',
model: 'm',
});
const backend = new ByokBackend({
resolver,
providers: [makeProvider('openai')], // no gemini!
});
await expect(
backend.generate({
taskName: 'test',
contentClass: 'personal',
messages: [{ role: 'user', content: 'hi' }],
})
).rejects.toThrow(/Provider nicht unterstuetzt/);
});
it('onUsage callback fires after successful generation', async () => {
const onUsage = vi.fn();
const resolver: ByokKeyResolver = async () => ({
provider: 'openai',
apiKey: 'sk',
model: 'gpt-4o',
});
const backend = new ByokBackend({
resolver,
providers: [makeProvider('openai')],
onUsage,
});
await backend.generate({
taskName: 'test',
contentClass: 'personal',
messages: [{ role: 'user', content: 'hi' }],
});
expect(onUsage).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openai',
model: 'gpt-4o',
promptTokens: 10,
completionTokens: 20,
})
);
});
it('onUsage does not fire when usage is missing', async () => {
const onUsage = vi.fn();
const call = async (): Promise<GenerateResult> => ({
content: 'x',
latencyMs: 0,
// no usage field
});
const resolver: ByokKeyResolver = async () => ({
provider: 'openai',
apiKey: 'sk',
model: 'gpt-4o',
});
const backend = new ByokBackend({
resolver,
providers: [makeProvider('openai', call)],
onUsage,
});
await backend.generate({
taskName: 'test',
contentClass: 'personal',
messages: [{ role: 'user', content: 'hi' }],
});
expect(onUsage).not.toHaveBeenCalled();
});
it('invalidateAvailability resets the cached flag', async () => {
const backend = new ByokBackend({
resolver: async () => null,
providers: [makeProvider('openai')],
});
await backend.isReady(); // sets internal flag to false
expect(backend.isAvailable()).toBe(false);
backend.invalidateAvailability();
expect(backend.isAvailable()).toBe(true); // back to unknown/available
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { estimateCost, formatCost, MODEL_PRICING } from './pricing';
describe('estimateCost', () => {
it('computes cost for known model', () => {
// gpt-4o-mini: input 0.3/M, output 1.2/M
// 1M input + 0.5M output = 0.3 + 0.6 = 0.9
const cost = estimateCost('gpt-4o-mini', 1_000_000, 500_000);
expect(cost).toBeCloseTo(0.9, 4);
});
it('returns 0 for unknown model', () => {
expect(estimateCost('unknown-model-xyz', 1000, 500)).toBe(0);
});
it('handles zero tokens', () => {
expect(estimateCost('gpt-4o', 0, 0)).toBe(0);
});
it('handles only input tokens', () => {
// claude-opus-4-6: input 15/M, output 75/M
const cost = estimateCost('claude-opus-4-6', 1_000_000, 0);
expect(cost).toBe(15);
});
it('handles only output tokens', () => {
// gemini-2.5-flash: input 0.15/M, output 0.6/M
const cost = estimateCost('gemini-2.5-flash', 0, 1_000_000);
expect(cost).toBe(0.6);
});
it('scales linearly with token count', () => {
const cost1k = estimateCost('gpt-4o', 1000, 1000);
const cost10k = estimateCost('gpt-4o', 10_000, 10_000);
expect(cost10k).toBeCloseTo(cost1k * 10, 6);
});
it('has pricing for all OpenAI models', () => {
const openaiModels = [
'gpt-5',
'gpt-5-mini',
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'o1',
'o1-mini',
];
for (const model of openaiModels) {
expect(MODEL_PRICING[model]).toBeDefined();
}
});
it('has pricing for all Anthropic models', () => {
const anthropicModels = [
'claude-opus-4-6',
'claude-opus-4-5',
'claude-sonnet-4-6',
'claude-sonnet-4-5',
'claude-haiku-4-5',
];
for (const model of anthropicModels) {
expect(MODEL_PRICING[model]).toBeDefined();
}
});
it('has pricing for all Gemini models', () => {
const geminiModels = [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.0-flash',
];
for (const model of geminiModels) {
expect(MODEL_PRICING[model]).toBeDefined();
}
});
});
describe('formatCost', () => {
it('shows dash for zero', () => {
expect(formatCost(0)).toBe('—');
});
it('shows "< $0.0001" for very small amounts', () => {
expect(formatCost(0.00001)).toBe('< $0.0001');
});
it('shows 4 decimals for amounts < 0.01', () => {
expect(formatCost(0.005)).toBe('$0.0050');
});
it('shows 3 decimals for amounts < 1', () => {
expect(formatCost(0.123)).toBe('$0.123');
});
it('shows 2 decimals for amounts >= 1', () => {
expect(formatCost(1.234)).toBe('$1.23');
expect(formatCost(100.567)).toBe('$100.57');
});
});