A cap sized for the wrong workload
The Anthropic output cap was 16000 with a comment claiming "~120 one-line settings". The system prompt actually demands full coverage of the entire selector + token-scope catalog — hundreds of directives — so a comprehensive theme could exceed 16k and be cut off. Anthropic signals that with stop_reason: "max_tokens", which the stream never inspects, so truncation looked like a clean completion.
Cap raised to 32000; the comment now states the real worst case.
src/adapters/anthropic/gateway.ts · 60 lines
src/adapters/anthropic/gateway.ts
⋯ 8 lines hidden (lines 1–8)
1import Anthropic from '@anthropic-ai/sdk';
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';
8// A comprehensive theme covers the full selector + token-scope catalog (hundreds of
9// one-line SELECTOR/TOKEN directives, plus interspersed MESSAGE lines), so the cap is
10// sized for that worst case with headroom — not the ~120 a typical theme emits. (The
11// OpenAI adapter sets no cap; Chat Completions defaults to the model maximum.)
12const MAX_TOKENS = 32000;
⋯ 35 lines hidden (lines 14–48)
14const classify = (e: unknown): ProviderError =>
15 e instanceof Anthropic.APIError ? classifyByStatus(e.status, e.message) : { _tag: 'Network' };
17const clientFor = (key: Redacted<ApiKey>): Anthropic => new Anthropic({ apiKey: expose(key) });
19export async function* toContentStream(
20 stream: AsyncIterable<Anthropic.Messages.RawMessageStreamEvent>,
21): AsyncIterable<string> {
22 // A failure *after* the stream opens (429 once tokens flow, dropped socket, idle
23 // timeout) surfaces by throwing here. Classify it so the consumer sees a typed
24 // ProviderError instead of a raw SDK error / unhandled rejection.
25 try {
26 for await (const event of stream) {
27 if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
28 yield event.delta.text;
29 }
30 }
31 } catch (e) {
32 throw classify(e);
33 }
34}
36export const createAnthropicAdapter = (): ProviderAdapter => ({
37 verify: (key) =>
38 AsyncResult.tryCatch(async () => {
39 await clientFor(key).models.list();
40 }, classify),
42 streamTheme: async (
43 request: ProviderRequest,
44 ): AsyncResultType<ProviderError, AsyncIterable<string>> => {
45 const opened = await AsyncResult.tryCatch(
46 () =>
47 clientFor(request.key).messages.create({
48 model: modelText(request.model),
49 max_tokens: MAX_TOKENS,
⋯ 11 lines hidden (lines 50–60)
50 // The 1,000-line prompt is static — cache it so repeat generations are
51 // cheaper and lower-latency within the cache TTL.
52 system: [{ type: 'text', text: request.system, cache_control: { type: 'ephemeral' } }],
53 messages: [{ role: 'user', content: request.user }],
54 stream: true,
55 }),
56 classify,
57 );
58 return opened._tag === 'Err' ? opened : ok(toContentStream(opened.value));
59 },
60});