Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
54e85dd7fThis file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execa } from 'execa';
import { makeProvider, runAgentLoop } from '../src/agent/loop.js';
import { OpenAIProvider } from '../src/agent/providers/openai.js';
import {
DEFAULT_API_KEY_ENV,
isLocalEndpoint,
isCompatModel,
loadConfig,
resolveCompatSettings,
type CopperheadConfig,
DEFAULTS,
} from '../src/config.js';
import { tempFixtureRepo } from './helpers.js';
const base: CopperheadConfig = { schematic: null, board: null, ...DEFAULTS };
async function repoWithConfig(raw: Record<string, unknown>): Promise<{ dir: string; cleanup: () => Promise<void> }> {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-compat-'));
await mkdir(path.join(dir, '.copperhead'), { recursive: true });
await writeFile(path.join(dir, '.copperhead', 'config.json'), JSON.stringify(raw), 'utf8');
return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
}
describe('compat settings resolution (D1/D2)', () => {
it('defaults to OPENAI_API_KEY and no baseURL', () => {
const s = resolveCompatSettings(base, {});
expect(s.apiKeyEnv).toBe(DEFAULT_API_KEY_ENV);
expect(s.baseURL).toBeUndefined();
});
it('reads baseURL and apiKeyEnv from config', async () => {
const { dir, cleanup } = await repoWithConfig({
baseURL: 'https://api.groq.com/openai/v1',
apiKeyEnv: 'GROQ_API_KEY',
});
try {
const cfg = await loadConfig(dir);
const s = resolveCompatSettings(cfg, {});
expect(s.baseURL).toBe('https://api.groq.com/openai/v1');
expect(s.apiKeyEnv).toBe('GROQ_API_KEY');
} finally {
await cleanup();
}
});
it('environment overrides config, matching resolveModel precedence', () => {
const cfg = { ...base, baseURL: 'https://from-config/v1', apiKeyEnv: 'CONFIG_KEY' };
const s = resolveCompatSettings(cfg, {
COPPERHEAD_BASE_URL: 'https://from-env/v1',
COPPERHEAD_API_KEY_ENV: 'ENV_KEY',
});
expect(s.baseURL).toBe('https://from-env/v1');
expect(s.apiKeyEnv).toBe('ENV_KEY');
});
it('ignores blank values rather than treating them as configured', async () => {
const { dir, cleanup } = await repoWithConfig({ baseURL: ' ', apiKeyEnv: '' });
try {
const cfg = await loadConfig(dir);
expect(cfg.baseURL).toBeUndefined();
expect(resolveCompatSettings(cfg, {}).apiKeyEnv).toBe(DEFAULT_API_KEY_ENV);
} finally {
await cleanup();
}
});
it('isLocalEndpoint recognises loopback hosts only', () => {
for (const u of [
'http://localhost:11434/v1',
'http://127.0.0.1:8080/v1',
'http://ollama.local/v1',
// ::1 is IPv6 loopback and, per AC-3.14, must be recognised the same
// way as 127.0.0.1 — bracketed form is what a real URL requires.
'http://[::1]:11434/v1',
]) {
expect(isLocalEndpoint(u), u).toBe(true);
}
for (const u of ['https://api.groq.com/openai/v1', 'https://openrouter.ai/api/v1', undefined, 'not a url']) {
expect(isLocalEndpoint(u as string | undefined), String(u)).toBe(false);
}
});
it('isCompatModel identifies exactly the compat route, matching makeProvider\'s own gate', () => {
for (const m of ['compat', 'compat:qwen-3-coder', 'compat:llama-3.1-8b-instant']) {
expect(isCompatModel(m), m).toBe(true);
}
for (const m of ['gpt-5', 'gpt-5-mini', 'claude', 'claude-opus-4-5', 'codex', 'cursor', 'claude-code']) {
expect(isCompatModel(m), m).toBe(false);
}
});
});
describe('OpenAIProvider — compatible endpoints', () => {
it('reads the key from the configured variable name, not always OPENAI_API_KEY', () => {
const p = new OpenAIProvider(
'qwen-3-coder',
{ baseURL: 'https://api.groq.com/openai/v1', apiKeyEnv: 'GROQ_API_KEY' },
{ GROQ_API_KEY: 'gsk-test' },
);
// A compat endpoint gets a distinct name so otherProvider() (loop.ts)
// never treats it as real OpenAI and fails it over to a paid key.
expect(p.name).toBe('openai-compat');
});
// The headline claim of this change is "point it anywhere and it works", so
// verify the request really lands on the configured endpoint rather than
// asserting on a constructed object. A loopback server keeps it hermetic:
// no egress, no key, deterministic.
it('actually issues its request to the configured baseURL, with the model and key', async () => {
const seen: { url?: string; auth?: string; body?: Record<string, unknown> } = {};
const server = createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
seen.url = req.url;
seen.auth = req.headers.authorization;
seen.body = JSON.parse(raw) as Record<string, unknown>;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
choices: [{ message: { content: 'pong', tool_calls: [] } }],
usage: { prompt_tokens: 7, completion_tokens: 3 },
}),
);
});
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address() as AddressInfo;
try {
const p = new OpenAIProvider(
'qwen-3-coder',
{ baseURL: `http://127.0.0.1:${port}/v1`, apiKeyEnv: 'GROQ_API_KEY' },
{ GROQ_API_KEY: 'gsk-test' },
);
const turn = await p.chat([{ role: 'user', content: 'ping' }], []);
expect(seen.url).toBe('/v1/chat/completions'); // baseURL honoured, path appended
expect(seen.auth).toBe('Bearer gsk-test'); // key from GROQ_API_KEY, not OPENAI_API_KEY
expect(seen.body?.model).toBe('qwen-3-coder'); // model id passed through
expect(turn.text).toBe('pong');
expect(turn.usage).toEqual({ inputTokens: 7, outputTokens: 3 });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it('a keyless loopback endpoint still sends a request the server accepts (Ollama shape)', async () => {
let hit = false;
const server = createServer((req, res) => {
hit = true;
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ choices: [{ message: { content: 'ok' } }], usage: {} }));
});
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address() as AddressInfo;
try {
const p = new OpenAIProvider('llama3', { baseURL: `http://localhost:${port}/v1`, apiKeyEnv: 'UNUSED' }, {});
const turn = await p.chat([{ role: 'user', content: 'hi' }], []);
expect(hit).toBe(true);
expect(turn.text).toBe('ok');
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it('a remote endpoint without its key throws, naming the variable it expected', () => {
expect(
() =>
new OpenAIProvider('qwen-3-coder', { baseURL: 'https://api.groq.com/openai/v1', apiKeyEnv: 'GROQ_API_KEY' }, {}),
).toThrow('GROQ_API_KEY is not set');
});
it('a loopback endpoint constructs with no key at all (D4: Ollama)', () => {
expect(
() => new OpenAIProvider('llama3', { baseURL: 'http://localhost:11434/v1', apiKeyEnv: 'UNUSED' }, {}),
).not.toThrow();
});
it('defaults cleanly with no options, resolving the key only through env (never a literal)', () => {
// OpenAIProviderOptions has no `apiKey` field: a key can only ever reach
// the provider by being present under the configured env var name,
// enforced at compile time (an `apiKey` property here would not typecheck).
expect(() => new OpenAIProvider('gpt-5', undefined, { OPENAI_API_KEY: 'sk-test' })).not.toThrow();
expect(() => new OpenAIProvider('gpt-5', undefined, {})).toThrow('OPENAI_API_KEY is not set');
});
});
describe('makeProvider — compat routing', () => {
const groq = { baseURL: 'https://api.groq.com/openai/v1', apiKeyEnv: 'GROQ_API_KEY' };
const withKey = async <T>(fn: () => Promise<T>): Promise<T> => {
process.env.GROQ_API_KEY = 'gsk-test';
try {
// Must await here, not just return the promise: the `finally` below
// would otherwise run at fn()'s first `await` (i.e. immediately, since
// fn() returns synchronously before yielding), deleting the env var
// before the callback's own awaits — including the credential read
// inside makeProvider — actually settle.
return await fn();
} finally {
delete process.env.GROQ_API_KEY;
}
};
it('routes compat:<model-id> to the OpenAI provider', async () => {
await withKey(async () => {
const p = await makeProvider('compat:qwen-3-coder', false, groq);
expect(p).toBeInstanceOf(OpenAIProvider);
expect(p.name).toBe('openai-compat');
});
});
it('has a distinct name so otherProvider() never fails a rate-limited compat run over to a paid key', async () => {
// otherProvider() (loop.ts) only swaps openai<->anthropic by exact name
// match. Before this fix OpenAIProvider.name was the fixed literal
// 'openai' regardless of baseURL, so a 429 against a free/local compat
// endpoint (which routinely 429s) would silently fail over to the real,
// paid Anthropic API if ANTHROPIC_API_KEY happened to be set.
await withKey(async () => {
const p = await makeProvider('compat:qwen-3-coder', false, groq);
expect(['openai', 'anthropic']).not.toContain(p.name);
});
});
it('rejects an empty override, like every other prefixed route', async () => {
await expect(makeProvider('compat:', false, groq)).rejects.toThrow('compat model override cannot be empty');
});
it('refuses bare `compat` with no endpoint configured', async () => {
await expect(makeProvider('compat', false, { apiKeyEnv: DEFAULT_API_KEY_ENV })).rejects.toThrow(
/requires a model id and an endpoint/,
);
});
it('refuses bare `compat` even when an endpoint IS configured — no silent default model (regression)', async () => {
// Bug: `if (!compatModel && !settings.baseURL)` only threw when BOTH were
// missing, so bare `compat` with a configured baseURL fell through to
// `new OpenAIProvider(undefined, ...)`, which silently defaulted to the
// literal model id "gpt-5" — sent to whatever host baseURL pointed at.
// Unlike gpt-5/claude, a compat endpoint has no id that is ever correct to
// assume, so this must throw regardless of whether baseURL is set.
await withKey(async () => {
await expect(makeProvider('compat', false, groq)).rejects.toThrow(/requires a model id/);
});
});
it('refuses compat:<model-id> with no endpoint configured — no silent fallback to real OpenAI (regression)', async () => {
// Bug: a model id alone skipped the guard entirely, so `new OpenAIProvider`
// was built with no baseURL and fell back to its own default client —
// silently hitting real api.openai.com with a non-OpenAI model id and
// whatever key apiKeyEnv resolved to.
await withKey(async () => {
await expect(makeProvider('compat:llama-3.1-8b-instant', false, { apiKeyEnv: 'GROQ_API_KEY' })).rejects.toThrow(
/requires an endpoint/,
);
});
});
it('surfaces the missing key for a remote endpoint', async () => {
await expect(makeProvider('compat:qwen-3-coder', false, groq)).rejects.toThrow('GROQ_API_KEY is not set');
});
it('falls back to DEFAULT_API_KEY_ENV when the compat settings argument is omitted entirely', async () => {
// Both production call sites (loop.ts, create.ts) always resolve settings
// via resolveCompatSettings() first, so this default arm
// (`compat ?? { apiKeyEnv: DEFAULT_API_KEY_ENV }`) only exercises when a
// caller omits the third argument, as a direct makeProvider('compat:...')
// call (no settings) would.
await expect(makeProvider('compat:qwen-3-coder')).rejects.toThrow(/requires an endpoint/);
});
it('a stray COPPERHEAD_BASE_URL never redirects a plain gpt-5 run (D2)', async () => {
// resolveCompatSettings correctly reports the env base URL — that alone
// does not prove makeProvider ignores it. Route through the real makeProvider,
// passing the resolved settings through exactly as loop.ts/create.ts do, so a
// future regression that accidentally threads `compat` into the non-compat
// fallback branch would actually fail this test.
const s = resolveCompatSettings(base, { COPPERHEAD_BASE_URL: 'https://evil.example/v1' });
expect(s.baseURL).toBe('https://evil.example/v1');
process.env.OPENAI_API_KEY = 'sk-test';
try {
const p = await makeProvider('gpt-5', false, s);
expect(JSON.stringify(p)).not.toContain('evil.example');
} finally {
delete process.env.OPENAI_API_KEY;
}
});
});
describe('config.json baseURL reaches a real do run (offline, no injected provider)', () => {
// Every other test in this file (and every offline loop test elsewhere)
// injects opts.provider, so runAgentLoop's own
// `opts.provider ?? (await makeProvider(opts.model, sessionResume, resolveCompatSettings(config)))`
// (loop.ts) never evaluates its right-hand side. Nothing else proves a
// `baseURL` configured in .copperhead/config.json actually reaches the
// request on a real `do` run — only the opt-in live matrix does, which is
// skipped by default. This test crosses that seam for real, with a
// loopback server standing in for a compat endpoint.
it('a compat baseURL configured in .copperhead/config.json is honoured with no injected provider', async () => {
const { repo, cleanup } = await tempFixtureRepo();
const seen: { url?: string; auth?: string; model?: unknown } = {};
const server = createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
seen.url = req.url;
seen.auth = req.headers.authorization;
const body = JSON.parse(raw) as { model?: unknown };
seen.model = body.model;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
choices: [
{
message: {
content: null,
tool_calls: [
{
id: 'fin',
type: 'function',
function: {
name: 'finish',
arguments: JSON.stringify({ outcome: 'refuse', summary: 'nothing to do (test)' }),
},
},
],
},
},
],
usage: { prompt_tokens: 11, completion_tokens: 4 },
}),
);
});
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address() as AddressInfo;
try {
await mkdir(path.join(repo, '.copperhead'), { recursive: true });
await writeFile(
path.join(repo, '.copperhead', 'config.json'),
JSON.stringify({ baseURL: `http://127.0.0.1:${port}/v1`, apiKeyEnv: 'COMPAT_TEST_KEY' }),
'utf8',
);
await execa('git', ['add', '-A'], { cwd: repo });
await execa('git', ['commit', '-q', '-m', 'compat config'], { cwd: repo });
process.env.COMPAT_TEST_KEY = 'compat-test-key-value';
let res;
try {
res = await runAgentLoop({
repoRoot: repo,
request: 'compat baseURL threading test',
model: 'compat:qwen-3-coder',
log: () => {},
meta: { command: 'do', modelSource: 'flag', version: '0.0.0-test', kicadCliVersion: '0.0.0' },
});
} finally {
delete process.env.COMPAT_TEST_KEY;
}
expect(seen.url).toBe('/v1/chat/completions');
expect(seen.auth).toBe('Bearer compat-test-key-value');
expect(seen.model).toBe('qwen-3-coder');
expect(res.outcome).toBe('refused');
} finally {
await new Promise<void>((r) => server.close(() => r()));
await cleanup();
}
});
});