返回 CodeWhale
check-provider-registry.py
根目录 / scripts / check-provider-registry.py
1 #!/usr/bin/env python3
2 """Check that docs/PROVIDERS.md tracks the shipped provider registry.
3
4 This is intentionally lightweight. It does not try to generate prose; it checks
5 the stable identifiers and default strings that are easy for docs to drift from:
6
7 - canonical ProviderKind IDs
8 - provider TOML tables
9 - live TUI ApiProvider IDs
10 - shipped-provider table rows
11 - static ModelRegistry provider rows
12 - default provider model/base URL constants
13 """
14
15 from __future__ import annotations
16
17 import re
18 import sys
19 from pathlib import Path
20
21
22 ROOT = Path(__file__).resolve().parents[1]
23 CONFIG_RS = ROOT / "crates" / "config" / "src" / "lib.rs"
24 # ProviderKind's enum + identity impl were split out of lib.rs into this module.
25 PROVIDER_KIND_RS = ROOT / "crates" / "config" / "src" / "provider_kind.rs"
26 PROVIDER_RS = ROOT / "crates" / "config" / "src" / "provider.rs"
27 TUI_CONFIG_RS = ROOT / "crates" / "tui" / "src" / "config.rs"
28 # Default provider model/base-URL constants were split out of config.rs into
29 # this leaf module (#3311); read them from there for the default-string check.
30 TUI_CONFIG_MODELS_RS = ROOT / "crates" / "tui" / "src" / "config" / "models.rs"
31 AGENT_RS = ROOT / "crates" / "agent" / "src" / "lib.rs"
32 PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md"
33
34
35 API_PROVIDER_ONLY_IDS = {"deepseek-cn"}
36
37 # `custom` is the dynamic OpenAI-compatible meta-provider (#1519): a single
38 # catch-all `[providers.custom]` table that backs arbitrary user-defined
39 # endpoints, not a canonical shipped provider with a docs row. It is excluded
40 # from the provider-table drift check.
41 META_PROVIDER_TABLES = {"custom"}
42 SHARED_PROVIDER_TABLES = {
43 "siliconflow-CN": "siliconflow_cn",
44 }
45 HUGGINGFACE_ALIASES = {"huggingface", "hugging-face", "hugging_face", "hf"}
46 HUGGINGFACE_API_KEY_ENV_ORDER = ["HUGGINGFACE_API_KEY", "HF_TOKEN"]
47 HUGGINGFACE_BASE_URL_ENV_ORDER = ["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]
48 HUGGINGFACE_MODEL_ENV_ORDER = ["HUGGINGFACE_MODEL", "HF_MODEL"]
49 SENSITIVE_IDENTIFIER_RE = re.compile(r"(?i)(api[_-]?key|token|secret|password|credential)")
50 SENSITIVE_BEARER_RE = re.compile(r"(?i)(authorization:\s*bearer\s+)\S+")
51 SENSITIVE_ASSIGNMENT_RE = re.compile(
52 r"(?i)\b(api[_-]?key|token|secret|password|credential)(\s*[:=]\s*)\S+"
53 )
54
55
56 def read(path: Path) -> str:
57 return path.read_text(encoding="utf-8")
58
59
60 def display_public_value(value: str) -> str:
61 if SENSITIVE_IDENTIFIER_RE.search(value):
62 return "<redacted sensitive identifier>"
63 return value
64
65
66 def redact_sensitive_text(value: str) -> str:
67 value = SENSITIVE_BEARER_RE.sub(r"\1<redacted>", value)
68 value = SENSITIVE_ASSIGNMENT_RE.sub(r"\1\2<redacted>", value)
69 return SENSITIVE_IDENTIFIER_RE.sub("<redacted sensitive identifier>", value)
70
71
72 def require_index(source: str, needle: str, context: str, start: int = 0) -> int:
73 try:
74 return source.index(needle, start)
75 except ValueError:
76 raise ValueError(f"{context}: missing {needle!r}") from None
77
78
79 def markdown_section(source: str, heading: str) -> str:
80 start = require_index(source, heading, "docs/PROVIDERS.md")
81 next_heading = source.find("\n## ", start + len(heading))
82 end = len(source) if next_heading == -1 else next_heading
83 return source[start:end]
84
85
86 def extract_match_block(
87 source: str, signature: str, context: str, start: int = 0
88 ) -> str:
89 start = require_index(source, signature, context, start)
90 match_start = require_index(source, "match", f"match block after {signature!r}", start)
91 brace_start = require_index(source, "{", f"match block after {signature!r}", match_start)
92 depth = 0
93 for index in range(brace_start, len(source)):
94 char = source[index]
95 if char == "{":
96 depth += 1
97 elif char == "}":
98 depth -= 1
99 if depth == 0:
100 return source[brace_start + 1 : index]
101 raise ValueError(f"could not parse match block after {signature!r}")
102
103
104 def parse_aliases_for_variant(source: str, enum_name: str, variant: str, context: str) -> set[str]:
105 # `ProviderKind`'s enum + identity impl (incl. `parse`) live in
106 # provider_kind.rs after the config module split; read the impl from there
107 # regardless of the file the caller passed for other lookups.
108 if enum_name == "ProviderKind":
109 source = read(PROVIDER_KIND_RS)
110 context = "crates/config/src/provider_kind.rs"
111 impl_start = require_index(source, f"impl {enum_name}", context)
112 block = extract_match_block(
113 source,
114 "pub fn parse(value: &str) -> Option<Self>",
115 context,
116 impl_start,
117 )
118 match_arm = re.search(
119 rf'((?:"[^"]+"\s*\|\s*)*"[^"]+")\s*=>\s*Some\(Self::{variant}\)',
120 block,
121 )
122 if match_arm:
123 return set(re.findall(r'"([^"]+)"', match_arm.group(1)))
124 if enum_name in {"ProviderKind", "ApiProvider"}:
125 provider_rs = read(PROVIDER_RS)
126 provider_macro = re.search(
127 rf'provider!\(\s*\n\s*\w+,\s*\n\s*{variant},\s*\n\s*"([^"]+)".*?'
128 r"aliases:\s*\[(.*?)\]\s*\);",
129 provider_rs,
130 re.DOTALL,
131 )
132 if provider_macro:
133 return {provider_macro.group(1)} | set(
134 re.findall(r'"([^"]+)"', provider_macro.group(2))
135 )
136 raise ValueError(f"{context}: missing parse arm for {variant}")
137
138
139 def provider_kind_ids(config_rs: str) -> dict[str, str]:
140 provider_rs = read(PROVIDER_RS)
141 pairs = re.findall(
142 r"provider!\(\s*\n\s*\w+,\s*\n\s*(\w+),\s*\n\s*\"([^\"]+)\"",
143 provider_rs,
144 )
145 ids: dict[str, str] = {variant: provider_id for variant, provider_id in pairs}
146 # Providers with non-fixed wire policy or custom auth behavior use manual
147 # impls rather than the provider!() macro.
148 for variant_name, id_literal in [
149 ("Deepseek", "deepseek"),
150 ("DeepseekAnthropic", "deepseek-anthropic"),
151 ("OpenaiCodex", "openai-codex"),
152 ("Anthropic", "anthropic"),
153 ("Openmodel", "openmodel"),
154 ("MinimaxAnthropic", "minimax-anthropic"),
155 ("OpencodeZen", "opencode-zen"),
156 # Alibaba Model Studio ships four plan/dialect identities, each with a
157 # hand-written impl Provider for the same reason as the rows above:
158 # the wire policy is not fixed, so provider!() cannot express them.
159 ("ModelstudioTokenPlan", "modelstudio-token-plan"),
160 ("ModelstudioTokenPlanAnthropic", "modelstudio-token-plan-anthropic"),
161 ("ModelstudioCodingPlan", "modelstudio-coding-plan"),
162 ("ModelstudioCodingPlanAnthropic", "modelstudio-coding-plan-anthropic"),
163 ]:
164 match = re.search(
165 rf'impl\s+Provider\s+for\s+{variant_name}.*?fn\s+id.*?\"({id_literal})\"',
166 provider_rs, re.DOTALL,
167 )
168 if match:
169 ids[variant_name] = match.group(1)
170 if not ids:
171 raise ValueError("provider!() invocations returned no providers")
172 return ids
173
174
175 def api_provider_ids(tui_config_rs: str) -> dict[str, str]:
176 # ApiProvider ids derive from ProviderKind ids (via delegation to .kind().as_str())
177 # plus the legacy "deepseek-cn" variant that exists only in ApiProvider.
178 variant_to_id = provider_kind_ids("")
179 # ApiProvider::SiliconflowCn maps to ProviderKind::SiliconflowCN
180 if "SiliconflowCN" in variant_to_id:
181 variant_to_id["SiliconflowCn"] = variant_to_id["SiliconflowCN"]
182 variant_to_id["DeepseekCN"] = "deepseek-cn"
183 return variant_to_id
184
185
186 def provider_tables(config_rs: str) -> set[str]:
187 struct_start = require_index(
188 config_rs, "pub struct ProvidersToml", "crates/config/src/lib.rs"
189 )
190 struct_end = require_index(config_rs, "\n}", "ProvidersToml struct", struct_start)
191 fields = re.findall(
192 r"pub\s+([a-z0-9_]+)\s*:\s*ProviderConfigToml",
193 config_rs[struct_start:struct_end],
194 )
195 if not fields:
196 raise ValueError("ProvidersToml returned no provider tables")
197 return set(fields)
198
199
200 def shipped_provider_rows(providers_md: str) -> set[str]:
201 table = markdown_section(providers_md, "## Shipped Providers")
202 return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE))
203
204
205 def shipped_provider_tables(providers_md: str) -> set[str]:
206 table = markdown_section(providers_md, "## Shipped Providers")
207 return set(re.findall(r"\|\s*`\[providers\.([a-z0-9_]+)\]`\s*\|", table))
208
209
210 def static_registry_provider_rows(providers_md: str) -> set[str]:
211 table = markdown_section(providers_md, "## Static Model Registry")
212 return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE))
213
214
215 def model_registry_providers(agent_rs: str, variant_to_id: dict[str, str]) -> set[str]:
216 variants = set(re.findall(r"provider:\s*ProviderKind::(\w+)", agent_rs))
217 missing = variants - set(variant_to_id)
218 if missing:
219 raise ValueError(f"ModelRegistry uses unknown provider variants: {sorted(missing)}")
220 return {variant_to_id[variant] for variant in variants}
221
222
223 def default_strings(tui_config_rs: str) -> set[str]:
224 # Model/base-URL constants now live in config/models.rs (#3311); scan it
225 # alongside config.rs so the check follows the leaf split.
226 sources = tui_config_rs + "\n" + read(TUI_CONFIG_MODELS_RS)
227 defaults = set()
228 for name, value in re.findall(
229 r'const\s+(DEFAULT_[A-Z0-9_]+(?:MODEL|BASE_URL)):\s*&str\s*=\s*"([^"]+)"',
230 sources,
231 ):
232 if name == "DEFAULT_DEEPSEEKCN_BASE_URL":
233 continue
234 defaults.add(value)
235 if not defaults:
236 raise ValueError("no default provider model/base URL constants found")
237 return defaults
238
239
240 def missing_default_strings(providers_md: str, defaults: set[str]) -> list[str]:
241 # Inline-code validation should not let fenced TOML/bash examples pair a
242 # stray backtick with later prose; strip fenced blocks before scanning.
243 inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL)
244 code_spans = set(re.findall(r"`([^`]+)`", inline_source))
245 return sorted(defaults - code_spans)
246
247
248 def report_set(label: str, expected: set[str], actual: set[str]) -> list[str]:
249 errors = []
250 missing = sorted(expected - actual)
251 extra = sorted(actual - expected)
252 if missing:
253 errors.append(f"{label} missing: {', '.join(missing)}")
254 if extra:
255 errors.append(f"{label} extra: {', '.join(extra)}")
256 return errors
257
258
259 def report_provider_enum_drift(
260 provider_kind_ids: set[str], api_provider_ids: set[str]
261 ) -> list[str]:
262 errors = []
263 missing_from_api_provider = sorted(provider_kind_ids - api_provider_ids)
264 unexpected_api_provider_ids = sorted(
265 api_provider_ids - provider_kind_ids - API_PROVIDER_ONLY_IDS
266 )
267 missing_allowlisted_ids = sorted(API_PROVIDER_ONLY_IDS - api_provider_ids)
268
269 if missing_from_api_provider:
270 errors.append(
271 "ApiProvider missing ProviderKind IDs: "
272 + ", ".join(missing_from_api_provider)
273 )
274 if unexpected_api_provider_ids:
275 errors.append(
276 "ApiProvider has non-whitelisted IDs absent from ProviderKind: "
277 + ", ".join(unexpected_api_provider_ids)
278 )
279 if missing_allowlisted_ids:
280 errors.append(
281 "ApiProvider-only whitelist entries are absent from ApiProvider: "
282 + ", ".join(missing_allowlisted_ids)
283 )
284 return errors
285
286
287 def report_huggingface_coverage(
288 config_rs: str, tui_config_rs: str, providers_md: str
289 ) -> list[str]:
290 errors = []
291
292 config_aliases = parse_aliases_for_variant(
293 config_rs, "ProviderKind", "Huggingface", "crates/config/src/lib.rs"
294 )
295 tui_aliases = parse_aliases_for_variant(
296 tui_config_rs, "ApiProvider", "Huggingface", "crates/tui/src/config.rs"
297 )
298 errors += report_set(
299 "ProviderKind Hugging Face aliases",
300 HUGGINGFACE_ALIASES,
301 config_aliases & HUGGINGFACE_ALIASES,
302 )
303 errors += report_set(
304 "ApiProvider Hugging Face aliases",
305 HUGGINGFACE_ALIASES,
306 tui_aliases & HUGGINGFACE_ALIASES,
307 )
308
309 inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL)
310 code_spans = set(re.findall(r"`([^`]+)`", inline_source))
311 errors += report_set(
312 "documented Hugging Face aliases",
313 HUGGINGFACE_ALIASES,
314 code_spans & HUGGINGFACE_ALIASES,
315 )
316
317 for label, env_order in [
318 ("Hugging Face auth env precedence", HUGGINGFACE_API_KEY_ENV_ORDER),
319 ("Hugging Face base URL env precedence", HUGGINGFACE_BASE_URL_ENV_ORDER),
320 ("Hugging Face model env precedence", HUGGINGFACE_MODEL_ENV_ORDER),
321 ]:
322 errors += report_env_lookup_order(
323 label, config_rs, env_order, "crates/config/src/lib.rs"
324 )
325 errors += report_env_lookup_order(
326 label, tui_config_rs, env_order, "crates/tui/src/config.rs"
327 )
328 errors += report_string_order(label, providers_md, env_order, "docs/PROVIDERS.md")
329
330 return errors
331
332
333 def report_env_lookup_order(
334 label: str, source: str, expected_order: list[str], context: str
335 ) -> list[str]:
336 lookup_needles = [f'std::env::var("{name}")' for name in expected_order]
337 return report_string_order(label, source, lookup_needles, context)
338
339
340 def report_string_order(
341 label: str, source: str, expected_order: list[str], context: str
342 ) -> list[str]:
343 contains_sensitive_expected_value = any(
344 SENSITIVE_IDENTIFIER_RE.search(value) for value in expected_order
345 )
346 positions = []
347 for needle in expected_order:
348 index = source.find(needle)
349 if index == -1:
350 if contains_sensitive_expected_value:
351 return [f"{label} missing required entry in {context}"]
352 return [f"{label} missing {display_public_value(needle)!r} in {context}"]
353 positions.append(index)
354 if positions != sorted(positions):
355 if contains_sensitive_expected_value:
356 return [f"{label} has wrong order in {context}"]
357 return [
358 f"{label} has wrong order in {context}: expected "
359 + " before ".join(display_public_value(value) for value in expected_order)
360 ]
361 return []
362
363
364 def provider_table_name(provider_id: str) -> str:
365 return SHARED_PROVIDER_TABLES.get(provider_id, provider_id.replace("-", "_"))
366
367
368 def main() -> int:
369 try:
370 config_rs = read(CONFIG_RS)
371 tui_config_rs = read(TUI_CONFIG_RS)
372 agent_rs = read(AGENT_RS)
373 providers_md = read(PROVIDERS_MD)
374
375 variant_to_id = provider_kind_ids(config_rs)
376 canonical_ids = set(variant_to_id.values())
377 live_api_provider_ids = set(api_provider_ids(tui_config_rs).values())
378 expected_tables = {provider_table_name(provider_id) for provider_id in canonical_ids}
379
380 errors: list[str] = []
381 errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids)
382 errors += report_huggingface_coverage(config_rs, tui_config_rs, providers_md)
383 errors += report_set(
384 "shipped provider rows",
385 canonical_ids,
386 shipped_provider_rows(providers_md),
387 )
388 errors += report_set(
389 "provider TOML tables",
390 expected_tables,
391 provider_tables(config_rs) - META_PROVIDER_TABLES,
392 )
393 errors += report_set(
394 "documented provider TOML tables",
395 expected_tables,
396 shipped_provider_tables(providers_md),
397 )
398 errors += report_set(
399 "static ModelRegistry rows",
400 model_registry_providers(agent_rs, variant_to_id),
401 static_registry_provider_rows(providers_md),
402 )
403
404 missing_defaults = missing_default_strings(providers_md, default_strings(tui_config_rs))
405 if missing_defaults:
406 errors.append(
407 "docs/PROVIDERS.md does not mention default strings as Markdown code spans: "
408 + ", ".join(missing_defaults)
409 )
410 except ValueError as err:
411 errors = [str(err)]
412
413 if errors:
414 print("Provider registry drift check failed:", file=sys.stderr)
415 for error in errors:
416 print(f"- {redact_sensitive_text(error)}", file=sys.stderr)
417 return 1
418
419 print("Provider registry drift check passed.")
420 return 0
421
422
423 if __name__ == "__main__":
424 raise SystemExit(main())
425
425 lines PYTHON