The untested edge

The harness fakes the entire ModelGateway port, so the application loop is well tested but the provider-specific edge — reasoning-effort gating, status→error classification, SDK-event extraction, and the dispatch itself — had zero coverage. A regex typo or a dropped status mapping would pass the whole suite. This PR covers that edge and dedupes the classifier on the way.

Dedupe: one shared classifier

Both adapters had a byte-identical status→ProviderError block. Extract it; each adapter keeps only its SDK-specific instanceof guard and delegates.

The shared mapping, the one place the status table lives.

src/adapters/classify.ts · 24 lines
src/adapters/classify.ts24 lines · TypeScript
1/**
2 * Shared error classification for provider adapters. Both SDKs expose a numeric
3 * `.status` and a `.message` on their `APIError`, so the status→`ProviderError`
4 * mapping is identical; only the SDK-specific `instanceof` guard differs. Each
5 * adapter keeps that two-line guard and delegates here.
6 */
7 
8import { type ProviderError } from '../ports';
9 
10export const classifyByStatus = (
11 status: number | undefined,
12 message: string,
13): ProviderError => {
14 if (status === 401 || status === 403) {
15 return { _tag: 'AuthFailed' };
16 }
17 if (status === 429) {
18 return { _tag: 'RateLimited' };
19 }
20 if (status === undefined) {
21 return { _tag: 'Network' };
22 }
23 return { _tag: 'Unexpected', detail: message };
24};

classify is now a 2-line guard; isReasoningModel and toContentStream are exported for tests.

src/adapters/openai/gateway.ts · 64 lines
src/adapters/openai/gateway.ts64 lines · TypeScript
⋯ 8 lines hidden (lines 1–8)
1import OpenAI from 'openai';
2import { AsyncResult, type AsyncResultType, expose, ok, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { modelText } from '../../domain/model';
5import { type ProviderAdapter, type ProviderError, type ProviderRequest } from '../../ports';
6import { classifyByStatus } from '../classify';
7 
8/** Classify an SDK error by status code, instead of v1's substring sniffing. */
9const classify = (e: unknown): ProviderError =>
10 e instanceof OpenAI.APIError ? classifyByStatus(e.status, e.message) : { _tag: 'Network' };
⋯ 8 lines hidden (lines 11–18)
11 
12const clientFor = (key: Redacted<ApiKey>): OpenAI => new OpenAI({ apiKey: expose(key) });
13 
14// GPT-5 / o-series are reasoning models; `minimal` effort keeps them fast and
15// streaming token-by-token (the "watch it paint" UX) rather than pausing to think.
16// Non-reasoning custom models (e.g. gpt-4o) reject the parameter, so only send it
17// where it's supported.
18const REASONING_FAMILY = /^(gpt-5|o[0-9])/i;
19export const isReasoningModel = (id: string): boolean => REASONING_FAMILY.test(id);
20 
21export async function* toContentStream(
⋯ 43 lines hidden (lines 22–64)
22 stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
23): AsyncIterable<string> {
24 // A failure *after* the stream opens (429 once tokens flow, dropped socket, idle
25 // timeout) surfaces by throwing here. Classify it so the consumer sees a typed
26 // ProviderError instead of a raw SDK error / unhandled rejection.
27 try {
28 for await (const chunk of stream) {
29 const content = chunk.choices[0]?.delta?.content;
30 if (content) {
31 yield content;
32 }
33 }
34 } catch (e) {
35 throw classify(e);
36 }
38 
39export const createOpenAiAdapter = (): ProviderAdapter => ({
40 verify: (key) =>
41 AsyncResult.tryCatch(async () => {
42 await clientFor(key).models.list();
43 }, classify),
44 
45 streamTheme: async (
46 request: ProviderRequest,
47 ): AsyncResultType<ProviderError, AsyncIterable<string>> => {
48 const id = modelText(request.model);
49 const opened = await AsyncResult.tryCatch(
50 () =>
51 clientFor(request.key).chat.completions.create({
52 model: id,
53 messages: [
54 { role: 'system', content: request.system },
55 { role: 'user', content: request.user },
56 ],
57 stream: true,
58 ...(isReasoningModel(id) ? { reasoning_effort: 'minimal' as const } : {}),
59 }),
60 classify,
61 );
62 return opened._tag === 'Err' ? opened : ok(toContentStream(opened.value));
63 },
64});

The tests

classifyByStatus mappings; the reasoning gate (gpt-5*/o* yes, gpt-4.x/claude no); toContentStream concatenation, empty-delta skip, and the mid-stream re-throw; and a direct dispatcher test that proves routing by provider and that the provider field is dropped before reaching a ProviderAdapter.

classifyByStatus, the reasoning gate, and the OpenAI stream extraction + throw.

test/adapters.test.ts · 135 lines
test/adapters.test.ts135 lines · TypeScript
⋯ 20 lines hidden (lines 1–20)
1/**
2 * Unit tests for the provider adapters' pure edge logic — the code the gateway
3 * fake in harness.ts deliberately bypasses, so nothing else exercises it. A
4 * reasoning_effort gate typo, a dropped status mapping, or a broken SDK-event
5 * extraction would otherwise pass the whole suite.
6 */
7 
8import { describe, it } from 'node:test';
9import assert from 'node:assert/strict';
10import { ok, redact, type NonEmptyString, type Redacted } from '../src/fp';
11import { type ApiKey } from '../src/domain/apiKey';
12import { makeModel, modelText, type ModelId } from '../src/domain/model';
13import { classifyByStatus } from '../src/adapters/classify';
14import { createModelGateway } from '../src/adapters/gateway';
15import { isReasoningModel, toContentStream as openAiStream } from '../src/adapters/openai/gateway';
16import { toContentStream as anthropicStream } from '../src/adapters/anthropic/gateway';
17import { type ProviderAdapter, type ProviderRequest } from '../src/ports';
18 
19const cast = <T>(value: unknown): T => value as T;
20 
21describe('classifyByStatus', () => {
22 it('maps HTTP status to a ProviderError', () => {
23 assert.deepEqual(classifyByStatus(401, 'm'), { _tag: 'AuthFailed' });
24 assert.deepEqual(classifyByStatus(403, 'm'), { _tag: 'AuthFailed' });
25 assert.deepEqual(classifyByStatus(429, 'm'), { _tag: 'RateLimited' });
26 assert.deepEqual(classifyByStatus(undefined, 'm'), { _tag: 'Network' });
27 assert.deepEqual(classifyByStatus(500, 'boom'), { _tag: 'Unexpected', detail: 'boom' });
28 });
29});
30 
31describe('isReasoningModel (OpenAI reasoning_effort gate)', () => {
32 it('matches gpt-5 and o-series, not gpt-4.x / non-OpenAI', () => {
33 for (const id of ['gpt-5.5', 'gpt-5.4-mini', 'o3', 'o4-mini', 'GPT-5.5']) {
34 assert.equal(isReasoningModel(id), true, id);
35 }
36 for (const id of ['gpt-4o', 'gpt-4.1', 'claude-sonnet-4-6']) {
37 assert.equal(isReasoningModel(id), false, id);
38 }
39 });
40});
41 
42describe('toContentStream — OpenAI', () => {
43 it('concatenates delta content and skips empty deltas', async () => {
44 async function* chunks(): AsyncIterable<unknown> {
45 yield { choices: [{ delta: { content: 'Hel' } }] };
46 yield { choices: [{ delta: {} }] };
47 yield { choices: [{ delta: { content: 'lo' } }] };
48 }
49 let out = '';
50 for await (const s of openAiStream(cast(chunks()))) {
51 out += s;
52 }
53 assert.equal(out, 'Hello');
54 });
55 
56 it('re-throws a classified ProviderError on a mid-stream throw', async () => {
57 async function* boom(): AsyncIterable<unknown> {
58 yield { choices: [{ delta: { content: 'x' } }] };
59 throw new Error('socket reset');
60 }
61 await assert.rejects(
62 (async () => {
63 for await (const _s of openAiStream(cast(boom()))) {
64 /* drain */
65 }
66 })(),
67 (e: unknown) => (e as { _tag?: string })._tag === 'Network',
68 );
69 });
70});
⋯ 65 lines hidden (lines 71–135)
71 
72describe('toContentStream — Anthropic', () => {
73 it('yields only text_delta events', async () => {
74 async function* events(): AsyncIterable<unknown> {
75 yield { type: 'message_start' };
76 yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hi ' } };
77 yield { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{}' } };
78 yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'there' } };
79 }
80 let out = '';
81 for await (const s of anthropicStream(cast(events()))) {
82 out += s;
83 }
84 assert.equal(out, 'Hi there');
85 });
86});
87 
88describe('createModelGateway dispatch', () => {
89 const recorder = (): {
90 adapter: ProviderAdapter;
91 verified: Array<Redacted<ApiKey>>;
92 streamed: ProviderRequest[];
93 } => {
94 const verified: Array<Redacted<ApiKey>> = [];
95 const streamed: ProviderRequest[] = [];
96 const adapter: ProviderAdapter = {
97 verify: async (key) => {
98 verified.push(key);
99 return ok(undefined);
100 },
101 streamTheme: async (req) => {
102 streamed.push(req);
103 return ok((async function* () {})());
104 },
105 };
106 return { adapter, verified, streamed };
107 };
108 
109 const key = redact(('sk-' + 'x'.repeat(40)) as ApiKey);
110 const model: ModelId = makeModel('anthropic', 'claude-x').id;
111 
112 it('routes to the adapter named by the request provider, dropping the provider field', async () => {
113 const oa = recorder();
114 const an = recorder();
115 const gw = createModelGateway({ openai: oa.adapter, anthropic: an.adapter });
116 
117 await gw.streamTheme({
118 provider: 'anthropic',
119 key,
120 model,
121 system: 'sys' as NonEmptyString,
122 user: 'usr' as NonEmptyString,
123 });
124 assert.equal(an.streamed.length, 1);
125 assert.equal(oa.streamed.length, 0);
126 const req = an.streamed[0];
127 assert.ok(req);
128 assert.equal(modelText(req.model), 'claude-x');
129 assert.equal(Object.prototype.hasOwnProperty.call(req, 'provider'), false);
130 
131 await gw.verify('openai', key);
132 assert.equal(oa.verified.length, 1);
133 assert.equal(an.verified.length, 0);
134 });
135});

The dispatcher routes by provider, drops the provider field, verify hits only the named adapter.

test/adapters.test.ts · 135 lines
test/adapters.test.ts135 lines · TypeScript
⋯ 87 lines hidden (lines 1–87)
1/**
2 * Unit tests for the provider adapters' pure edge logic — the code the gateway
3 * fake in harness.ts deliberately bypasses, so nothing else exercises it. A
4 * reasoning_effort gate typo, a dropped status mapping, or a broken SDK-event
5 * extraction would otherwise pass the whole suite.
6 */
7 
8import { describe, it } from 'node:test';
9import assert from 'node:assert/strict';
10import { ok, redact, type NonEmptyString, type Redacted } from '../src/fp';
11import { type ApiKey } from '../src/domain/apiKey';
12import { makeModel, modelText, type ModelId } from '../src/domain/model';
13import { classifyByStatus } from '../src/adapters/classify';
14import { createModelGateway } from '../src/adapters/gateway';
15import { isReasoningModel, toContentStream as openAiStream } from '../src/adapters/openai/gateway';
16import { toContentStream as anthropicStream } from '../src/adapters/anthropic/gateway';
17import { type ProviderAdapter, type ProviderRequest } from '../src/ports';
18 
19const cast = <T>(value: unknown): T => value as T;
20 
21describe('classifyByStatus', () => {
22 it('maps HTTP status to a ProviderError', () => {
23 assert.deepEqual(classifyByStatus(401, 'm'), { _tag: 'AuthFailed' });
24 assert.deepEqual(classifyByStatus(403, 'm'), { _tag: 'AuthFailed' });
25 assert.deepEqual(classifyByStatus(429, 'm'), { _tag: 'RateLimited' });
26 assert.deepEqual(classifyByStatus(undefined, 'm'), { _tag: 'Network' });
27 assert.deepEqual(classifyByStatus(500, 'boom'), { _tag: 'Unexpected', detail: 'boom' });
28 });
29});
30 
31describe('isReasoningModel (OpenAI reasoning_effort gate)', () => {
32 it('matches gpt-5 and o-series, not gpt-4.x / non-OpenAI', () => {
33 for (const id of ['gpt-5.5', 'gpt-5.4-mini', 'o3', 'o4-mini', 'GPT-5.5']) {
34 assert.equal(isReasoningModel(id), true, id);
35 }
36 for (const id of ['gpt-4o', 'gpt-4.1', 'claude-sonnet-4-6']) {
37 assert.equal(isReasoningModel(id), false, id);
38 }
39 });
40});
41 
42describe('toContentStream — OpenAI', () => {
43 it('concatenates delta content and skips empty deltas', async () => {
44 async function* chunks(): AsyncIterable<unknown> {
45 yield { choices: [{ delta: { content: 'Hel' } }] };
46 yield { choices: [{ delta: {} }] };
47 yield { choices: [{ delta: { content: 'lo' } }] };
48 }
49 let out = '';
50 for await (const s of openAiStream(cast(chunks()))) {
51 out += s;
52 }
53 assert.equal(out, 'Hello');
54 });
55 
56 it('re-throws a classified ProviderError on a mid-stream throw', async () => {
57 async function* boom(): AsyncIterable<unknown> {
58 yield { choices: [{ delta: { content: 'x' } }] };
59 throw new Error('socket reset');
60 }
61 await assert.rejects(
62 (async () => {
63 for await (const _s of openAiStream(cast(boom()))) {
64 /* drain */
65 }
66 })(),
67 (e: unknown) => (e as { _tag?: string })._tag === 'Network',
68 );
69 });
70});
71 
72describe('toContentStream — Anthropic', () => {
73 it('yields only text_delta events', async () => {
74 async function* events(): AsyncIterable<unknown> {
75 yield { type: 'message_start' };
76 yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hi ' } };
77 yield { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{}' } };
78 yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'there' } };
79 }
80 let out = '';
81 for await (const s of anthropicStream(cast(events()))) {
82 out += s;
83 }
84 assert.equal(out, 'Hi there');
85 });
86});
87 
88describe('createModelGateway dispatch', () => {
89 const recorder = (): {
90 adapter: ProviderAdapter;
91 verified: Array<Redacted<ApiKey>>;
92 streamed: ProviderRequest[];
93 } => {
94 const verified: Array<Redacted<ApiKey>> = [];
95 const streamed: ProviderRequest[] = [];
96 const adapter: ProviderAdapter = {
97 verify: async (key) => {
98 verified.push(key);
99 return ok(undefined);
100 },
101 streamTheme: async (req) => {
102 streamed.push(req);
103 return ok((async function* () {})());
104 },
105 };
106 return { adapter, verified, streamed };
107 };
108 
109 const key = redact(('sk-' + 'x'.repeat(40)) as ApiKey);
110 const model: ModelId = makeModel('anthropic', 'claude-x').id;
111 
112 it('routes to the adapter named by the request provider, dropping the provider field', async () => {
113 const oa = recorder();
114 const an = recorder();
115 const gw = createModelGateway({ openai: oa.adapter, anthropic: an.adapter });
116 
117 await gw.streamTheme({
118 provider: 'anthropic',
119 key,
120 model,
121 system: 'sys' as NonEmptyString,
122 user: 'usr' as NonEmptyString,
123 });
124 assert.equal(an.streamed.length, 1);
125 assert.equal(oa.streamed.length, 0);
126 const req = an.streamed[0];
127 assert.ok(req);
128 assert.equal(modelText(req.model), 'claude-x');
129 assert.equal(Object.prototype.hasOwnProperty.call(req, 'provider'), false);
130 
⋯ 5 lines hidden (lines 131–135)
131 await gw.verify('openai', key);
132 assert.equal(oa.verified.length, 1);
133 assert.equal(an.verified.length, 0);
134 });
135});