Skip to content

Commit d8ca03f

Browse files
committed
feat: add model name validation for docs code blocks
Allowlist-based CI lint that extracts model names from docs MDX code blocks and validates against docs/model-allowlist.json. Catches stale model references (gpt-5.2 after gpt-5.4 ships) before they reach users. - Regex extraction from model="...", model: "...", provider/model patterns - Provider prefix stripping (openai/, anthropic/, google/, etc.) - 20 unit tests
1 parent 25f3f70 commit d8ca03f

2 files changed

Lines changed: 591 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { describe, it, expect } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import * as os from "node:os";
5+
import {
6+
loadAllowlist,
7+
stripProviderPrefix,
8+
looksLikeModelName,
9+
extractModelNames,
10+
validateFiles,
11+
} from "../validate-doc-model-names";
12+
13+
// ---------------------------------------------------------------------------
14+
// stripProviderPrefix
15+
// ---------------------------------------------------------------------------
16+
17+
describe("stripProviderPrefix", () => {
18+
it("strips known provider prefixes", () => {
19+
expect(stripProviderPrefix("openai/gpt-5.4")).toBe("gpt-5.4");
20+
expect(stripProviderPrefix("anthropic/claude-sonnet-4")).toBe(
21+
"claude-sonnet-4",
22+
);
23+
expect(stripProviderPrefix("google/gemini-2.5-pro")).toBe("gemini-2.5-pro");
24+
expect(stripProviderPrefix("cohere/command-r-plus")).toBe("command-r-plus");
25+
expect(stripProviderPrefix("meta/llama-4-scout")).toBe("llama-4-scout");
26+
expect(stripProviderPrefix("mistral/mistral-large")).toBe("mistral-large");
27+
expect(stripProviderPrefix("azure/gpt-4o")).toBe("gpt-4o");
28+
expect(stripProviderPrefix("bedrock/claude-sonnet-4")).toBe(
29+
"claude-sonnet-4",
30+
);
31+
expect(stripProviderPrefix("vertex/gemini-2.5-flash")).toBe(
32+
"gemini-2.5-flash",
33+
);
34+
});
35+
36+
it("returns the name unchanged when no prefix matches", () => {
37+
expect(stripProviderPrefix("gpt-5.4")).toBe("gpt-5.4");
38+
expect(stripProviderPrefix("claude-sonnet-4")).toBe("claude-sonnet-4");
39+
});
40+
});
41+
42+
// ---------------------------------------------------------------------------
43+
// looksLikeModelName
44+
// ---------------------------------------------------------------------------
45+
46+
describe("looksLikeModelName", () => {
47+
it("recognizes known model prefixes", () => {
48+
expect(looksLikeModelName("gpt-5.4")).toBe(true);
49+
expect(looksLikeModelName("claude-sonnet-4")).toBe(true);
50+
expect(looksLikeModelName("gemini-2.5-pro")).toBe(true);
51+
expect(looksLikeModelName("o1")).toBe(true);
52+
expect(looksLikeModelName("o1-mini")).toBe(true);
53+
expect(looksLikeModelName("o3-mini")).toBe(true);
54+
expect(looksLikeModelName("o4-mini")).toBe(true);
55+
expect(looksLikeModelName("command-r-plus")).toBe(true);
56+
expect(looksLikeModelName("command-a")).toBe(true);
57+
expect(looksLikeModelName("mistral-large")).toBe(true);
58+
expect(looksLikeModelName("llama-4-scout")).toBe(true);
59+
});
60+
61+
it("rejects non-model strings", () => {
62+
expect(looksLikeModelName("react")).toBe(false);
63+
expect(looksLikeModelName("next.js")).toBe(false);
64+
expect(looksLikeModelName("typescript")).toBe(false);
65+
expect(looksLikeModelName("")).toBe(false);
66+
});
67+
});
68+
69+
// ---------------------------------------------------------------------------
70+
// extractModelNames
71+
// ---------------------------------------------------------------------------
72+
73+
describe("extractModelNames", () => {
74+
it('extracts model from model="..." in fenced code block', () => {
75+
const content = [
76+
"Some text",
77+
"```python",
78+
'ChatOpenAI(model="gpt-5.4-mini")',
79+
"```",
80+
].join("\n");
81+
82+
const results = extractModelNames(content);
83+
expect(results).toEqual([{ model: "gpt-5.4-mini", line: 3 }]);
84+
});
85+
86+
it('extracts model from model: "..." pattern', () => {
87+
const content = [
88+
"```tsx",
89+
'const config = { model: "claude-sonnet-4" };',
90+
"```",
91+
].join("\n");
92+
93+
const results = extractModelNames(content);
94+
expect(results).toEqual([{ model: "claude-sonnet-4", line: 2 }]);
95+
});
96+
97+
it('extracts model from "model": "..." JSON pattern', () => {
98+
const content = [
99+
"```json",
100+
"{",
101+
' "model": "gemini-2.5-flash"',
102+
"}",
103+
"```",
104+
].join("\n");
105+
106+
const results = extractModelNames(content);
107+
expect(results).toEqual([{ model: "gemini-2.5-flash", line: 3 }]);
108+
});
109+
110+
it("extracts model from single-quoted values", () => {
111+
const content = ["```python", "model='gpt-4o'", "```"].join("\n");
112+
113+
const results = extractModelNames(content);
114+
expect(results).toEqual([{ model: "gpt-4o", line: 2 }]);
115+
});
116+
117+
it("extracts model from inline code", () => {
118+
const content = 'Use `model="gpt-5.4"` for best results.';
119+
120+
const results = extractModelNames(content);
121+
expect(results).toEqual([{ model: "gpt-5.4", line: 1 }]);
122+
});
123+
124+
it("strips provider prefixes from model names", () => {
125+
const content = ["```tsx", 'model="openai/gpt-5.4-mini"', "```"].join("\n");
126+
127+
const results = extractModelNames(content);
128+
expect(results).toEqual([{ model: "gpt-5.4-mini", line: 2 }]);
129+
});
130+
131+
it("handles bare provider-prefixed names", () => {
132+
const content = ["```", "openai/gpt-4o-mini", "```"].join("\n");
133+
134+
const results = extractModelNames(content);
135+
expect(results).toEqual([{ model: "gpt-4o-mini", line: 2 }]);
136+
});
137+
138+
it("extracts multiple models from one file", () => {
139+
const content = [
140+
"```python",
141+
'a = ChatOpenAI(model="gpt-5.4")',
142+
'b = ChatAnthropic(model="claude-sonnet-4")',
143+
"```",
144+
].join("\n");
145+
146+
const results = extractModelNames(content);
147+
expect(results).toHaveLength(2);
148+
expect(results[0].model).toBe("gpt-5.4");
149+
expect(results[1].model).toBe("claude-sonnet-4");
150+
});
151+
152+
it("ignores text outside code blocks", () => {
153+
const content = [
154+
'We recommend model="gpt-5.4" for production.',
155+
"",
156+
"This is plain text, not code.",
157+
].join("\n");
158+
159+
// No fenced block, no inline code — nothing extracted
160+
const results = extractModelNames(content);
161+
expect(results).toEqual([]);
162+
});
163+
164+
it("ignores empty and whitespace-only strings", () => {
165+
const content = ["```", 'model=""', "model=' '", "```"].join("\n");
166+
167+
const results = extractModelNames(content);
168+
expect(results).toEqual([]);
169+
});
170+
171+
it("does not extract non-model strings from code blocks", () => {
172+
const content = [
173+
"```tsx",
174+
'const name = "react-component";',
175+
'import something from "next/router";',
176+
"```",
177+
].join("\n");
178+
179+
const results = extractModelNames(content);
180+
expect(results).toEqual([]);
181+
});
182+
});
183+
184+
// ---------------------------------------------------------------------------
185+
// loadAllowlist
186+
// ---------------------------------------------------------------------------
187+
188+
describe("loadAllowlist", () => {
189+
it("loads all model names from allowlist JSON", () => {
190+
const allowlistPath = path.resolve(
191+
__dirname,
192+
"../../docs/model-allowlist.json",
193+
);
194+
const allowed = loadAllowlist(allowlistPath);
195+
196+
expect(allowed.has("gpt-5.4")).toBe(true);
197+
expect(allowed.has("claude-sonnet-4")).toBe(true);
198+
expect(allowed.has("gemini-2.5-pro")).toBe(true);
199+
expect(allowed.has("command-r-plus")).toBe(true);
200+
expect(allowed.has("llama-4-scout")).toBe(true);
201+
});
202+
203+
it("excludes the _comment field", () => {
204+
const allowlistPath = path.resolve(
205+
__dirname,
206+
"../../docs/model-allowlist.json",
207+
);
208+
const allowed = loadAllowlist(allowlistPath);
209+
210+
// _comment value should not be in the set
211+
expect(
212+
allowed.has(
213+
"Maintained list of valid AI model names for docs. Update when providers release new models. CI validates docs against this list.",
214+
),
215+
).toBe(false);
216+
});
217+
});
218+
219+
// ---------------------------------------------------------------------------
220+
// validateFiles (integration)
221+
// ---------------------------------------------------------------------------
222+
223+
describe("validateFiles", () => {
224+
function createTempDir(): string {
225+
return fs.mkdtempSync(path.join(os.tmpdir(), "model-validate-"));
226+
}
227+
228+
it("returns no violations when all models are in the allowlist", () => {
229+
const dir = createTempDir();
230+
const allowlist = path.join(dir, "allowlist.json");
231+
232+
fs.writeFileSync(
233+
allowlist,
234+
JSON.stringify({ openai: ["gpt-5.4"], anthropic: ["claude-sonnet-4"] }),
235+
);
236+
fs.writeFileSync(
237+
path.join(dir, "test.mdx"),
238+
["```python", 'ChatOpenAI(model="gpt-5.4")', "```"].join("\n"),
239+
);
240+
241+
const violations = validateFiles(dir, allowlist);
242+
expect(violations).toEqual([]);
243+
244+
fs.rmSync(dir, { recursive: true });
245+
});
246+
247+
it("flags model names not in the allowlist", () => {
248+
const dir = createTempDir();
249+
const allowlist = path.join(dir, "allowlist.json");
250+
251+
fs.writeFileSync(allowlist, JSON.stringify({ openai: ["gpt-5.4"] }));
252+
fs.writeFileSync(
253+
path.join(dir, "test.mdx"),
254+
["```python", 'model="gpt-99"', "```"].join("\n"),
255+
);
256+
257+
const violations = validateFiles(dir, allowlist);
258+
expect(violations).toHaveLength(1);
259+
expect(violations[0].model).toBe("gpt-99");
260+
expect(violations[0].file).toBe("test.mdx");
261+
262+
fs.rmSync(dir, { recursive: true });
263+
});
264+
265+
it("scans subdirectories for .mdx files", () => {
266+
const dir = createTempDir();
267+
const sub = path.join(dir, "guides");
268+
fs.mkdirSync(sub);
269+
const allowlist = path.join(dir, "allowlist.json");
270+
271+
fs.writeFileSync(allowlist, JSON.stringify({ openai: ["gpt-5.4"] }));
272+
fs.writeFileSync(
273+
path.join(sub, "deep.mdx"),
274+
["```", 'model="gpt-unknown"', "```"].join("\n"),
275+
);
276+
277+
const violations = validateFiles(dir, allowlist);
278+
expect(violations).toHaveLength(1);
279+
expect(violations[0].file).toBe("guides/deep.mdx");
280+
281+
fs.rmSync(dir, { recursive: true });
282+
});
283+
});

0 commit comments

Comments
 (0)