Skip to content

Commit cd8c650

Browse files
ralyodioclaude
andauthored
generate-names: default to the frontier models, not the cheap tier (#16)
The defaults were gpt-4.1-mini and claude-haiku-4-5, and the names showed it: generic startup vocabulary, the same few stems recycled, and a drift off-brief on any description longer than a sentence. Now gpt-5.6-sol and claude-fable-5. The cheap tier was never the saving it looked like. This tool makes exactly ONE call per run whatever --count says — that is the whole design, since asking a model for a thousand names directly repeats itself within a few hundred. So the model is a rounding error against the value of a name you actually ship, and paying for the weaker one bought nothing. Two things the Anthropic path needed before claude-fable-5 could be the default: - max_tokens 4096 -> 16000. Fable 5 always thinks and thinking counts against max_tokens, so a budget that was ample for a non-thinking model can be spent before the JSON starts. - stop_reason "refusal" is now reported as a refusal. It arrives as HTTP 200 with no text block, so unhandled it reads as "the model returned nothing" and sends you to the parser rather than to the answer the API actually gave. Reading the text block by type rather than by position already handled the leading thinking block; there is now a test pinning that, and one pinning that this request never grows temperature/top_p/top_k — all three were removed on Fable 5 / Opus 5 / Sonnet 5 and are rejected with a 400. Note for anyone hitting this: every Anthropic key in the team vaults is spend-capped until 2026-09-01, so --provider anthropic returns a 400 until then and OpenAI is the working path. 187 tests pass (was 180), typecheck clean. Verified end to end against both the piped domainfree run and a bare default-model run. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9e22a07 commit cd8c650

3 files changed

Lines changed: 116 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ the same whether you ask for 10 names or 10,000.
275275
Needs a key — `cli-tools config set openai` stores one (see [API
276276
keys](#api-keys)), and `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` still work and
277277
take precedence. Whichever provider has a key is used; OpenAI wins if both do.
278-
Defaults are the cheap tier on each side (`gpt-4.1-mini` / `claude-haiku-4-5`)
278+
Defaults are the frontier tier on each side (`gpt-5.6-sol` / `claude-fable-5`)
279279
and are overridable with `--model`.
280280

281281
| Flag | Effect |

src/generate-names.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,28 @@
1313
export const DEFAULT_COUNT = 1000;
1414
export const DEFAULT_TLD = 'com';
1515

16-
/** Cheap-tier default per provider. Overridable with --model. */
16+
/**
17+
* Frontier default per provider. Overridable with --model.
18+
*
19+
* This was the cheap tier, and the names showed it: generic startup vocabulary,
20+
* the same handful of stems recycled, and a drift off-brief on any description
21+
* longer than a sentence. The whole design here is *one* call per run whatever
22+
* --count says, so the model is a rounding error against the value of a name
23+
* you actually ship — the cheap tier was never the saving it looked like.
24+
*/
1725
export const DEFAULT_MODELS = {
18-
openai: 'gpt-4.1-mini',
19-
anthropic: 'claude-haiku-4-5',
26+
openai: 'gpt-5.6-sol',
27+
anthropic: 'claude-fable-5',
2028
} as const;
2129

30+
/**
31+
* Claude Fable 5 always thinks, and thinking counts against max_tokens, so the
32+
* 4096 that was ample for a non-thinking model can be spent before the JSON
33+
* starts. 16000 is the documented non-streaming default and leaves room for
34+
* both.
35+
*/
36+
const ANTHROPIC_MAX_TOKENS = 16_000;
37+
2238
export type Provider = keyof typeof DEFAULT_MODELS;
2339

2440
export interface Vocabulary {
@@ -125,12 +141,30 @@ export function anthropicCaller(apiKey: string, model: string, timeoutMs: number
125141
},
126142
body: JSON.stringify({
127143
model,
128-
max_tokens: 4096,
144+
max_tokens: ANTHROPIC_MAX_TOKENS,
129145
messages: [{ role: 'user', content: prompt }],
130146
}),
131147
});
132148
if (!response.ok) throw new Error(`anthropic ${response.status}: ${await response.text()}`);
133-
const data = (await response.json()) as { content?: { type: string; text?: string }[] };
149+
const data = (await response.json()) as {
150+
content?: { type: string; text?: string }[];
151+
stop_reason?: string;
152+
stop_details?: { category?: string | null } | null;
153+
};
154+
155+
// A refusal is HTTP 200 with no text block. Without this it surfaces as
156+
// "the model returned nothing", which sends you looking at the prompt
157+
// parser rather than at the answer the API actually gave.
158+
if (data.stop_reason === 'refusal') {
159+
const category = data.stop_details?.category;
160+
throw new Error(
161+
`anthropic declined this request${category ? ` (${category})` : ''}. ` +
162+
'Rephrase the description, or use --provider openai.',
163+
);
164+
}
165+
166+
// Thinking models put a thinking block first; find the text one rather
167+
// than reading content[0].
134168
return data.content?.find((b) => b.type === 'text')?.text ?? '';
135169
} finally {
136170
clearTimeout(timer);

test/generate-names.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,88 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22
import {
33
type Vocabulary,
4+
anthropicCaller,
45
buildPrompt,
6+
DEFAULT_MODELS,
57
expand,
68
generateNames,
79
parseVocabulary,
810
resolveProvider,
911
} from '../src/generate-names.ts';
1012

13+
describe('DEFAULT_MODELS', () => {
14+
// The cheap tier produced generic startup vocabulary and drifted off-brief on
15+
// anything longer than a sentence. One call per run means the model is a
16+
// rounding error against the value of a usable name.
17+
it('is the frontier tier on both providers', () => {
18+
expect(DEFAULT_MODELS.openai).toBe('gpt-5.6-sol');
19+
expect(DEFAULT_MODELS.anthropic).toBe('claude-fable-5');
20+
});
21+
});
22+
23+
describe('anthropicCaller', () => {
24+
afterEach(() => vi.unstubAllGlobals());
25+
26+
function stub(body: unknown, ok = true) {
27+
const fetchMock = vi.fn(async () => ({
28+
ok,
29+
status: 200,
30+
json: async () => body,
31+
text: async () => JSON.stringify(body),
32+
}));
33+
vi.stubGlobal('fetch', fetchMock);
34+
return fetchMock;
35+
}
36+
37+
it('returns the text block', async () => {
38+
stub({ content: [{ type: 'text', text: '{"heads":[]}' }] });
39+
await expect(anthropicCaller('k', 'm', 1000)('p')).resolves.toBe('{"heads":[]}');
40+
});
41+
42+
// Fable 5 always thinks, and a thinking block comes first — reading
43+
// content[0] would return the empty reasoning rather than the answer.
44+
it('skips a leading thinking block', async () => {
45+
stub({
46+
content: [
47+
{ type: 'thinking', thinking: '' },
48+
{ type: 'text', text: '{"heads":["a"]}' },
49+
],
50+
});
51+
await expect(anthropicCaller('k', 'm', 1000)('p')).resolves.toBe('{"heads":["a"]}');
52+
});
53+
54+
// A refusal is HTTP 200 with no text block. Left unhandled it reads as "the
55+
// model returned nothing", which sends you to the parser instead of the API.
56+
it('reports a refusal as a refusal, with the category', async () => {
57+
stub({ content: [], stop_reason: 'refusal', stop_details: { category: 'cyber' } });
58+
await expect(anthropicCaller('k', 'm', 1000)('p')).rejects.toThrow(/declined.*cyber/);
59+
});
60+
61+
it('survives a refusal with no category', async () => {
62+
stub({ content: [], stop_reason: 'refusal', stop_details: null });
63+
await expect(anthropicCaller('k', 'm', 1000)('p')).rejects.toThrow(/declined/);
64+
});
65+
66+
it('leaves room for thinking in max_tokens', async () => {
67+
const fetchMock = stub({ content: [{ type: 'text', text: '{}' }] });
68+
await anthropicCaller('k', 'm', 1000)('p');
69+
const body = JSON.parse((fetchMock.mock.calls[0] as never[])[1]!['body'] as string);
70+
expect(body.max_tokens).toBeGreaterThanOrEqual(16_000);
71+
});
72+
73+
// Sampling parameters were removed on Fable 5 / Opus 5 / Sonnet 5 and are
74+
// rejected with a 400, so this request must never grow one.
75+
it('sends no sampling parameters', async () => {
76+
const fetchMock = stub({ content: [{ type: 'text', text: '{}' }] });
77+
await anthropicCaller('k', 'm', 1000)('p');
78+
const body = JSON.parse((fetchMock.mock.calls[0] as never[])[1]!['body'] as string);
79+
expect(body).not.toHaveProperty('temperature');
80+
expect(body).not.toHaveProperty('top_p');
81+
expect(body).not.toHaveProperty('top_k');
82+
expect(body).not.toHaveProperty('thinking');
83+
});
84+
});
85+
1186
describe('resolveProvider', () => {
1287
it('prefers OpenAI when both keys are set', () => {
1388
expect(resolveProvider({ OPENAI_API_KEY: 'x', ANTHROPIC_API_KEY: 'y' })).toBe('openai');

0 commit comments

Comments
 (0)