返回 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 CONFIGURATION_MD = ROOT / "docs" / "CONFIGURATION.md"
34 WEB_FACTS_LIB = ROOT / "web" / "scripts" / "facts-lib.mjs"
35 WEB_FACTS_DRIFT = ROOT / "web" / "lib" / "facts-drift.ts"
36 WEB_FACTS_GENERATED = ROOT / "web" / "lib" / "facts.generated.ts"
37 README_MD = ROOT / "README.md"
38 CONFIG_EXAMPLE_TOML = ROOT / "config.example.toml"
39 TUI_PROVIDER_READINESS_RS = ROOT / "crates" / "tui" / "src" / "provider_readiness.rs"
40 TUI_LIB_RS = ROOT / "crates" / "tui" / "src" / "lib.rs"
41
42
43 API_PROVIDER_ONLY_IDS = {"deepseek-cn"}
44 LEGACY_PROVIDER_TOMBSTONE_IDS = {"antigravity"}
45 LEGACY_PROVIDER_TOMBSTONE_TABLES = {"antigravity"}
46 LEGACY_PROVIDER_SELECTION_IDS = {"antigravity", "agy"}
47
48 # `custom` is the dynamic OpenAI-compatible meta-provider (#1519): a single
49 # catch-all `[providers.custom]` table that backs arbitrary user-defined
50 # endpoints, not a canonical shipped provider with a docs row. It is excluded
51 # from the provider-table drift check.
52 META_PROVIDER_TABLES = {"custom"}
53 SHARED_PROVIDER_TABLES = {
54 "siliconflow-CN": "siliconflow_cn",
55 }
56 HUGGINGFACE_ALIASES = {"huggingface", "hugging-face", "hugging_face", "hf"}
57 HUGGINGFACE_API_KEY_ENV_ORDER = ["HUGGINGFACE_API_KEY", "HF_TOKEN"]
58 HUGGINGFACE_BASE_URL_ENV_ORDER = ["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]
59 HUGGINGFACE_MODEL_ENV_ORDER = ["HUGGINGFACE_MODEL", "HF_MODEL"]
60 SENSITIVE_IDENTIFIER_RE = re.compile(r"(?i)(api[_-]?key|token|secret|password|credential)")
61 SENSITIVE_BEARER_RE = re.compile(r"(?i)(authorization:\s*bearer\s+)\S+")
62 SENSITIVE_ASSIGNMENT_RE = re.compile(
63 r"(?i)\b(api[_-]?key|token|secret|password|credential)(\s*[:=]\s*)\S+"
64 )
65
66
67 def read(path: Path) -> str:
68 return path.read_text(encoding="utf-8")
69
70
71 def display_public_value(value: str) -> str:
72 if SENSITIVE_IDENTIFIER_RE.search(value):
73 return "<redacted sensitive identifier>"
74 return value
75
76
77 def redact_sensitive_text(value: str) -> str:
78 value = SENSITIVE_BEARER_RE.sub(r"\1<redacted>", value)
79 value = SENSITIVE_ASSIGNMENT_RE.sub(r"\1\2<redacted>", value)
80 return SENSITIVE_IDENTIFIER_RE.sub("<redacted sensitive identifier>", value)
81
82
83 def require_index(source: str, needle: str, context: str, start: int = 0) -> int:
84 try:
85 return source.index(needle, start)
86 except ValueError:
87 raise ValueError(f"{context}: missing {needle!r}") from None
88
89
90 def markdown_section(source: str, heading: str) -> str:
91 start = require_index(source, heading, "docs/PROVIDERS.md")
92 next_heading = source.find("\n## ", start + len(heading))
93 end = len(source) if next_heading == -1 else next_heading
94 return source[start:end]
95
96
97 def extract_match_block(
98 source: str, signature: str, context: str, start: int = 0
99 ) -> str:
100 start = require_index(source, signature, context, start)
101 match_start = require_index(source, "match", f"match block after {signature!r}", start)
102 brace_start = require_index(source, "{", f"match block after {signature!r}", match_start)
103 depth = 0
104 for index in range(brace_start, len(source)):
105 char = source[index]
106 if char == "{":
107 depth += 1
108 elif char == "}":
109 depth -= 1
110 if depth == 0:
111 return source[brace_start + 1 : index]
112 raise ValueError(f"could not parse match block after {signature!r}")
113
114
115 def parse_aliases_for_variant(source: str, enum_name: str, variant: str, context: str) -> set[str]:
116 # `ProviderKind`'s enum + identity impl (incl. `parse`) live in
117 # provider_kind.rs after the config module split; read the impl from there
118 # regardless of the file the caller passed for other lookups.
119 if enum_name == "ProviderKind":
120 source = read(PROVIDER_KIND_RS)
121 context = "crates/config/src/provider_kind.rs"
122 impl_start = require_index(source, f"impl {enum_name}", context)
123 block = extract_match_block(
124 source,
125 "pub fn parse(value: &str) -> Option<Self>",
126 context,
127 impl_start,
128 )
129 match_arm = re.search(
130 rf'((?:"[^"]+"\s*\|\s*)*"[^"]+")\s*=>\s*Some\(Self::{variant}\)',
131 block,
132 )
133 if match_arm:
134 return set(re.findall(r'"([^"]+)"', match_arm.group(1)))
135 if enum_name in {"ProviderKind", "ApiProvider"}:
136 provider_rs = read(PROVIDER_RS)
137 provider_macro = re.search(
138 rf'provider!\(\s*\n\s*\w+,\s*\n\s*{variant},\s*\n\s*"([^"]+)".*?'
139 r"aliases:\s*\[(.*?)\]\s*\);",
140 provider_rs,
141 re.DOTALL,
142 )
143 if provider_macro:
144 return {provider_macro.group(1)} | set(
145 re.findall(r'"([^"]+)"', provider_macro.group(2))
146 )
147 raise ValueError(f"{context}: missing parse arm for {variant}")
148
149
150 def provider_kind_ids(config_rs: str) -> dict[str, str]:
151 provider_rs = read(PROVIDER_RS)
152 pairs = re.findall(
153 r"provider!\(\s*\n\s*\w+,\s*\n\s*(\w+),\s*\n\s*\"([^\"]+)\"",
154 provider_rs,
155 )
156 ids: dict[str, str] = {variant: provider_id for variant, provider_id in pairs}
157 # Providers with non-fixed wire policy or custom auth behavior use manual
158 # impls rather than the provider!() macro. Discover them by shape rather
159 # than by name: a hand-maintained roster here goes stale the first time
160 # someone adds a provider, which is exactly how this guard first failed
161 # (it had never heard of `concentrate`).
162 for variant_name, id_literal in re.findall(
163 r'impl\s+Provider\s+for\s+(\w+)\s*\{.*?fn\s+id\s*\([^)]*\)[^{]*\{\s*"([^"]+)"',
164 provider_rs,
165 flags=re.DOTALL,
166 ):
167 # `Custom` is a meta provider, not a shipped vendor row: it is
168 # handled by META_PROVIDER_TABLES and must stay out of the canonical
169 # id set, or the shipped-row and TOML-table checks contradict.
170 if variant_name == "Custom":
171 continue
172 ids.setdefault(variant_name, id_literal)
173 # Kept as an explicit floor: if the shape scan ever stops matching one of
174 # these, the guard should fail loudly rather than silently cover less.
175 for variant_name, id_literal in [
176 ("Deepseek", "deepseek"),
177 ("DeepseekAnthropic", "deepseek-anthropic"),
178 ("OpenaiCodex", "openai-codex"),
179 ("Anthropic", "anthropic"),
180 ("Openmodel", "openmodel"),
181 ("MinimaxAnthropic", "minimax-anthropic"),
182 ("OpencodeZen", "opencode-zen"),
183 # Alibaba Model Studio ships four plan/dialect identities, each with a
184 # hand-written impl Provider for the same reason as the rows above:
185 # the wire policy is not fixed, so provider!() cannot express them.
186 ("ModelstudioTokenPlan", "modelstudio-token-plan"),
187 ("ModelstudioTokenPlanAnthropic", "modelstudio-token-plan-anthropic"),
188 ("ModelstudioCodingPlan", "modelstudio-coding-plan"),
189 ("ModelstudioCodingPlanAnthropic", "modelstudio-coding-plan-anthropic"),
190 ]:
191 match = re.search(
192 rf'impl\s+Provider\s+for\s+{variant_name}.*?fn\s+id.*?\"({id_literal})\"',
193 provider_rs, re.DOTALL,
194 )
195 if match:
196 ids[variant_name] = match.group(1)
197 elif variant_name not in ids:
198 raise ValueError(
199 f"expected a hand-written `impl Provider for {variant_name}` "
200 f"with id {id_literal!r}; the guard's floor is stale"
201 )
202 if not ids:
203 raise ValueError("provider!() invocations returned no providers")
204 return ids
205
206
207 def provider_kind_catalog_ids(
208 provider_kind_rs: str, variant_to_id: dict[str, str]
209 ) -> set[str]:
210 catalog = re.search(
211 r"pub const ALL:\s*\[Self;\s*\d+\]\s*=\s*\[(.*?)\];",
212 provider_kind_rs,
213 flags=re.DOTALL,
214 )
215 if catalog is None:
216 raise ValueError("crates/config/src/provider_kind.rs: missing ProviderKind::ALL")
217 variants = set(re.findall(r"Self::(\w+)", catalog.group(1)))
218 catalog_variant_to_id = {**variant_to_id, "Custom": "custom"}
219 missing = variants - set(catalog_variant_to_id)
220 if missing:
221 raise ValueError(f"ProviderKind::ALL uses unknown variants: {sorted(missing)}")
222 return {catalog_variant_to_id[variant] for variant in variants}
223
224
225 def api_provider_ids(tui_config_rs: str) -> dict[str, str]:
226 # ApiProvider ids derive from ProviderKind ids (via delegation to .kind().as_str())
227 # plus the legacy "deepseek-cn" variant that exists only in ApiProvider.
228 variant_to_id = provider_kind_ids("")
229 # ApiProvider::SiliconflowCn maps to ProviderKind::SiliconflowCN
230 if "SiliconflowCN" in variant_to_id:
231 variant_to_id["SiliconflowCn"] = variant_to_id["SiliconflowCN"]
232 variant_to_id["DeepseekCN"] = "deepseek-cn"
233 return variant_to_id
234
235
236 def provider_tables(config_rs: str) -> set[str]:
237 struct_start = require_index(
238 config_rs, "pub struct ProvidersToml", "crates/config/src/lib.rs"
239 )
240 struct_end = require_index(config_rs, "\n}", "ProvidersToml struct", struct_start)
241 fields = re.findall(
242 r"pub\s+([a-z0-9_]+)\s*:\s*ProviderConfigToml",
243 config_rs[struct_start:struct_end],
244 )
245 if not fields:
246 raise ValueError("ProvidersToml returned no provider tables")
247 return set(fields)
248
249
250 def shipped_provider_rows(providers_md: str) -> set[str]:
251 table = markdown_section(providers_md, "## Shipped Providers")
252 return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE))
253
254
255 def shipped_provider_tables(providers_md: str) -> set[str]:
256 table = markdown_section(providers_md, "## Shipped Providers")
257 return set(re.findall(r"\|\s*`\[providers\.([a-z0-9_]+)\]`\s*\|", table))
258
259
260 def documented_selectable_provider_ids(providers_md: str) -> set[str]:
261 marker = require_index(providers_md, "in that order:", "docs/PROVIDERS.md")
262 start = require_index(providers_md, "\n\n", "provider selection list", marker) + 2
263 end = require_index(providers_md, "\n\n", "provider selection list", start)
264 return set(re.findall(r"`([^`]+)`", providers_md[start:end]))
265
266
267 def report_provider_kind_selector_contract(provider_kind_rs: str) -> list[str]:
268 start = require_index(
269 provider_kind_rs,
270 "pub fn parse(value: &str) -> Option<Self>",
271 "ProviderKind::parse",
272 )
273 end = require_index(
274 provider_kind_rs, "pub fn parse_config_identity", "ProviderKind::parse", start
275 )
276 selector = provider_kind_rs[start:end]
277 if "Self::ALL" not in selector and "Self::all()" not in selector:
278 return [
279 "ProviderKind::parse must gate registry aliases through the selectable "
280 "ProviderKind::ALL catalog"
281 ]
282 return []
283
284
285 def report_tui_catalog_contract(tui_config_rs: str) -> list[str]:
286 start = require_index(
287 tui_config_rs, "pub fn catalog() -> &'static [Self]", "ApiProvider::catalog"
288 )
289 end = require_index(
290 tui_config_rs, "pub fn catalog_identity", "ApiProvider::catalog", start
291 )
292 catalog = tui_config_rs[start:end]
293 errors: list[str] = []
294 if (
295 "codewhale_config::ProviderKind::ALL" not in catalog
296 or "Antigravity" in catalog
297 ):
298 errors.append(
299 "ApiProvider::catalog must derive from ProviderKind::ALL without "
300 "legacy Antigravity"
301 )
302
303 impl_start = require_index(tui_config_rs, "impl ApiProvider", "ApiProvider impl")
304 parse_start = require_index(
305 tui_config_rs,
306 "pub fn parse(value: &str) -> Option<Self>",
307 "ApiProvider::parse",
308 impl_start,
309 )
310 parse_end = require_index(
311 tui_config_rs, "pub fn as_str", "ApiProvider::parse", parse_start
312 )
313 selector = tui_config_rs[parse_start:parse_end]
314 if (
315 "is_legacy_antigravity_identity(trimmed)" not in selector
316 or "return None" not in selector
317 ):
318 errors.append(
319 "ApiProvider::parse must reject both retired Antigravity config identities"
320 )
321 return errors
322
323
324 def report_tombstone_runtime_contract(
325 provider_kind_rs: str, tui_provider_readiness_rs: str, tui_lib_rs: str
326 ) -> list[str]:
327 """The tombstone must resolve under every legacy spelling and never read
328 as a credentialed or advertised slot on a running-product surface."""
329
330 errors: list[str] = []
331 start = require_index(
332 provider_kind_rs,
333 "pub fn parse_config_identity(value: &str) -> Option<Self>",
334 "ProviderKind::parse_config_identity",
335 )
336 end = require_index(
337 provider_kind_rs, "pub fn secret_store_slot", "ProviderKind::parse_config_identity", start
338 )
339 config_identity = provider_kind_rs[start:end]
340 if "parse_retired_alias" not in config_identity:
341 errors.append(
342 "ProviderKind::parse_config_identity must resolve retired registry aliases "
343 "(`agy`) so every selection surface can name the tombstone"
344 )
345
346 if (
347 "provider == ApiProvider::Antigravity || provider.kind().is_none()"
348 not in tui_provider_readiness_rs
349 ):
350 errors.append(
351 "provider_readiness::credential_state_for_provider must classify "
352 "ApiProvider::Antigravity as CredentialState::Legacy"
353 )
354
355 if "for provider in doctor_api_key_providers()" not in tui_lib_rs or (
356 "*provider != crate::config::ApiProvider::Antigravity" not in tui_lib_rs
357 ):
358 errors.append(
359 "`codewhale doctor` API Keys rows must iterate doctor_api_key_providers() "
360 "with the retired Antigravity slot filtered out"
361 )
362 return errors
363
364
365 def report_antigravity_public_contract(
366 providers_md: str,
367 configuration_md: str,
368 web_facts_lib: str,
369 web_facts_drift: str,
370 web_facts_generated: str,
371 readme_md: str,
372 config_example_toml: str,
373 ) -> list[str]:
374 """Keep the retired provider as one safe, non-runnable docs tombstone."""
375
376 errors: list[str] = []
377 heading = "### Legacy Antigravity tombstone"
378 heading_count = providers_md.count(heading)
379 if heading_count != 1:
380 errors.append(
381 "docs/PROVIDERS.md must contain exactly one legacy Antigravity tombstone "
382 f"heading (found {heading_count})"
383 )
384 tombstone = ""
385 outside_tombstone = providers_md
386 else:
387 start = providers_md.index(heading)
388 next_heading = re.search(r"\n#{1,3} ", providers_md[start + len(heading) :])
389 end = (
390 len(providers_md)
391 if next_heading is None
392 else start + len(heading) + next_heading.start()
393 )
394 tombstone = providers_md[start:end]
395 outside_tombstone = providers_md[:start] + providers_md[end:]
396
397 normalized_tombstone = " ".join(tombstone.split())
398 required_tombstone_copy = [
399 "not a Codewhale provider",
400 "cannot be selected or run",
401 "non-runnable migration tombstone",
402 "`codewhale auth clear --provider antigravity`",
403 "Codewhale-owned legacy configuration and consent metadata",
404 "does not sign out of, revoke, read, or otherwise alter any official Google or Antigravity session",
405 "supported `google` provider",
406 "`GEMINI_API_KEY`",
407 ]
408 missing_tombstone_copy = [
409 required
410 for required in required_tombstone_copy
411 if required not in normalized_tombstone
412 ]
413 if missing_tombstone_copy:
414 errors.append(
415 "legacy Antigravity tombstone is missing required safety or migration copy "
416 f"({len(missing_tombstone_copy)} checks failed)"
417 )
418 clear_command = "`codewhale auth clear --provider antigravity`"
419 legacy_provider_forms = [
420 match.lower()
421 for match in re.findall(
422 r"--provider\s+(antigravity|agy)\b", providers_md, flags=re.IGNORECASE
423 )
424 ]
425 if providers_md.count(clear_command) != 1 or legacy_provider_forms != [
426 "antigravity"
427 ]:
428 errors.append(
429 "docs/PROVIDERS.md must contain the Codewhale-owned Antigravity "
430 "clear command as its only --provider antigravity/agy form"
431 )
432 setup_guidance = re.search(
433 r"\bagy\b|\boauth\b|\blog(?:in|\s+in)\b|\bsign\s+in\b|"
434 r"\bimport\b|\bexternal-consent\b|/provider\s+(?:antigravity|agy)\b|"
435 r"CODEWHALE_PROVIDER\s*=\s*(?:antigravity|agy)\b",
436 tombstone,
437 flags=re.IGNORECASE,
438 )
439 if setup_guidance:
440 errors.append(
441 "legacy Antigravity tombstone contains login, OAuth import, consent, "
442 "or provider-selection guidance"
443 )
444
445 if re.search(r"\b(?:antigravity|agy)\b", outside_tombstone, flags=re.IGNORECASE):
446 errors.append(
447 "docs/PROVIDERS.md mentions Antigravity/agy outside its legacy tombstone"
448 )
449 if re.search(r"\b(?:antigravity|agy)\b", configuration_md, flags=re.IGNORECASE):
450 errors.append("docs/CONFIGURATION.md advertises retired Antigravity state")
451 if re.search(r"\b(?:antigravity|agy)\b", readme_md, flags=re.IGNORECASE):
452 errors.append("README.md advertises retired Antigravity state")
453 if re.search(r"\b(?:antigravity|agy)\b", config_example_toml, flags=re.IGNORECASE):
454 errors.append("config.example.toml advertises retired Antigravity state")
455 if "[providers.google]" not in config_example_toml or not re.search(
456 r"GEMINI_API_KEY", config_example_toml
457 ):
458 errors.append(
459 "config.example.toml must document the supported `google` Gemini route "
460 "with GEMINI_API_KEY"
461 )
462
463 forbidden_markers = {
464 "Antigravity API-key environment guidance": "ANTIGRAVITY_API_KEY",
465 "Antigravity ADC environment guidance": "AGY_ADC_AUTH",
466 "Antigravity base-URL environment guidance": "ANTIGRAVITY_BASE_URL",
467 "Antigravity model environment guidance": "ANTIGRAVITY_MODEL",
468 "private cloud-code endpoint guidance": "cloudcode-pa",
469 "private cloud-code protocol guidance": "cloud-code",
470 "official CLI credential-store guidance": "state.vscdb",
471 "official CLI OAuth-state guidance": "antigravityUnifiedStateSync",
472 "runnable legacy provider selection": 'provider = "antigravity"',
473 "runnable legacy provider table": "[providers.antigravity]",
474 }
475 public_sources = {
476 "docs/PROVIDERS.md": providers_md,
477 "docs/CONFIGURATION.md": configuration_md,
478 "web/scripts/facts-lib.mjs": web_facts_lib,
479 "web/lib/facts-drift.ts": web_facts_drift,
480 "web/lib/facts.generated.ts": web_facts_generated,
481 "README.md": readme_md,
482 "config.example.toml": config_example_toml,
483 }
484 for context, source in public_sources.items():
485 for description, marker in forbidden_markers.items():
486 if marker.lower() in source.lower():
487 errors.append(f"{context} contains forbidden {description}")
488
489 for context, source, exclusion_name, exclusion_filter in [
490 (
491 "web/scripts/facts-lib.mjs",
492 web_facts_lib,
493 "EXCLUDED_PROVIDERS",
494 ".filter((v) => !EXCLUDED_PROVIDERS.has(v))",
495 ),
496 (
497 "web/lib/facts-drift.ts",
498 web_facts_drift,
499 "EXCLUDED",
500 ".filter((v) => !EXCLUDED.has(v))",
501 ),
502 ]:
503 exclusion_decl = re.search(
504 rf"const\s+{exclusion_name}\s*=\s*new Set\(\[[^\]]*\"Antigravity\"",
505 source,
506 )
507 if exclusion_decl is None or exclusion_filter not in source:
508 errors.append(f"{context} does not explicitly exclude legacy Antigravity")
509 if re.search(r"^\s*Antigravity\s*:", source, flags=re.MULTILINE):
510 errors.append(f"{context} maps legacy Antigravity to public provider facts")
511 if re.search(r"\bagy\b", source, flags=re.IGNORECASE):
512 errors.append(f"{context} exposes the legacy agy alias")
513
514 if re.search(
515 r"\b(?:antigravity|agy)\b", web_facts_generated, flags=re.IGNORECASE
516 ):
517 errors.append("web/lib/facts.generated.ts exposes legacy Antigravity/agy")
518
519 return errors
520
521
522 def static_registry_provider_rows(providers_md: str) -> set[str]:
523 table = markdown_section(providers_md, "## Static Model Registry")
524 return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE))
525
526
527 def model_registry_providers(agent_rs: str, variant_to_id: dict[str, str]) -> set[str]:
528 variants = set(re.findall(r"provider:\s*ProviderKind::(\w+)", agent_rs))
529 missing = variants - set(variant_to_id)
530 if missing:
531 raise ValueError(f"ModelRegistry uses unknown provider variants: {sorted(missing)}")
532 return {variant_to_id[variant] for variant in variants}
533
534
535 def default_strings(tui_config_rs: str) -> set[str]:
536 # Model/base-URL constants now live in config/models.rs (#3311); scan it
537 # alongside config.rs so the check follows the leaf split.
538 sources = tui_config_rs + "\n" + read(TUI_CONFIG_MODELS_RS)
539 defaults = set()
540 for name, value in re.findall(
541 r'const\s+(DEFAULT_[A-Z0-9_]+(?:MODEL|BASE_URL)):\s*&str\s*=\s*"([^"]+)"',
542 sources,
543 ):
544 if name == "DEFAULT_DEEPSEEKCN_BASE_URL" or name.startswith(
545 "DEFAULT_ANTIGRAVITY_"
546 ):
547 continue
548 defaults.add(value)
549 if not defaults:
550 raise ValueError("no default provider model/base URL constants found")
551 return defaults
552
553
554 def missing_default_strings(providers_md: str, defaults: set[str]) -> list[str]:
555 # Inline-code validation should not let fenced TOML/bash examples pair a
556 # stray backtick with later prose; strip fenced blocks before scanning.
557 inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL)
558 code_spans = set(re.findall(r"`([^`]+)`", inline_source))
559 return sorted(defaults - code_spans)
560
561
562 def report_set(label: str, expected: set[str], actual: set[str]) -> list[str]:
563 errors = []
564 missing = sorted(expected - actual)
565 extra = sorted(actual - expected)
566 if missing:
567 errors.append(f"{label} missing: {', '.join(missing)}")
568 if extra:
569 errors.append(f"{label} extra: {', '.join(extra)}")
570 return errors
571
572
573 def report_provider_enum_drift(
574 provider_kind_ids: set[str], api_provider_ids: set[str]
575 ) -> list[str]:
576 errors = []
577 missing_from_api_provider = sorted(provider_kind_ids - api_provider_ids)
578 unexpected_api_provider_ids = sorted(
579 api_provider_ids - provider_kind_ids - API_PROVIDER_ONLY_IDS
580 )
581 missing_allowlisted_ids = sorted(API_PROVIDER_ONLY_IDS - api_provider_ids)
582
583 if missing_from_api_provider:
584 errors.append(
585 "ApiProvider missing ProviderKind IDs: "
586 + ", ".join(missing_from_api_provider)
587 )
588 if unexpected_api_provider_ids:
589 errors.append(
590 "ApiProvider has non-whitelisted IDs absent from ProviderKind: "
591 + ", ".join(unexpected_api_provider_ids)
592 )
593 if missing_allowlisted_ids:
594 errors.append(
595 "ApiProvider-only whitelist entries are absent from ApiProvider: "
596 + ", ".join(missing_allowlisted_ids)
597 )
598 return errors
599
600
601 def report_huggingface_coverage(
602 config_rs: str, tui_config_rs: str, providers_md: str
603 ) -> list[str]:
604 errors = []
605
606 config_aliases = parse_aliases_for_variant(
607 config_rs, "ProviderKind", "Huggingface", "crates/config/src/lib.rs"
608 )
609 tui_aliases = parse_aliases_for_variant(
610 tui_config_rs, "ApiProvider", "Huggingface", "crates/tui/src/config.rs"
611 )
612 errors += report_set(
613 "ProviderKind Hugging Face aliases",
614 HUGGINGFACE_ALIASES,
615 config_aliases & HUGGINGFACE_ALIASES,
616 )
617 errors += report_set(
618 "ApiProvider Hugging Face aliases",
619 HUGGINGFACE_ALIASES,
620 tui_aliases & HUGGINGFACE_ALIASES,
621 )
622
623 inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL)
624 code_spans = set(re.findall(r"`([^`]+)`", inline_source))
625 errors += report_set(
626 "documented Hugging Face aliases",
627 HUGGINGFACE_ALIASES,
628 code_spans & HUGGINGFACE_ALIASES,
629 )
630
631 for label, env_order in [
632 ("Hugging Face auth env precedence", HUGGINGFACE_API_KEY_ENV_ORDER),
633 ("Hugging Face base URL env precedence", HUGGINGFACE_BASE_URL_ENV_ORDER),
634 ("Hugging Face model env precedence", HUGGINGFACE_MODEL_ENV_ORDER),
635 ]:
636 errors += report_env_lookup_order(
637 label, config_rs, env_order, "crates/config/src/lib.rs"
638 )
639 errors += report_env_lookup_order(
640 label, tui_config_rs, env_order, "crates/tui/src/config.rs"
641 )
642 errors += report_string_order(label, providers_md, env_order, "docs/PROVIDERS.md")
643
644 return errors
645
646
647 def report_env_lookup_order(
648 label: str, source: str, expected_order: list[str], context: str
649 ) -> list[str]:
650 lookup_needles = [f'std::env::var("{name}")' for name in expected_order]
651 return report_string_order(label, source, lookup_needles, context)
652
653
654 def report_string_order(
655 label: str, source: str, expected_order: list[str], context: str
656 ) -> list[str]:
657 contains_sensitive_expected_value = any(
658 SENSITIVE_IDENTIFIER_RE.search(value) for value in expected_order
659 )
660 positions = []
661 for needle in expected_order:
662 index = source.find(needle)
663 if index == -1:
664 if contains_sensitive_expected_value:
665 return [f"{label} missing required entry in {context}"]
666 return [f"{label} missing {display_public_value(needle)!r} in {context}"]
667 positions.append(index)
668 if positions != sorted(positions):
669 if contains_sensitive_expected_value:
670 return [f"{label} has wrong order in {context}"]
671 return [
672 f"{label} has wrong order in {context}: expected "
673 + " before ".join(display_public_value(value) for value in expected_order)
674 ]
675 return []
676
677
678 def provider_table_name(provider_id: str) -> str:
679 return SHARED_PROVIDER_TABLES.get(provider_id, provider_id.replace("-", "_"))
680
681
682 def main() -> int:
683 try:
684 config_rs = read(CONFIG_RS)
685 provider_kind_rs = read(PROVIDER_KIND_RS)
686 tui_config_rs = read(TUI_CONFIG_RS)
687 agent_rs = read(AGENT_RS)
688 providers_md = read(PROVIDERS_MD)
689 configuration_md = read(CONFIGURATION_MD)
690 web_facts_lib = read(WEB_FACTS_LIB)
691 web_facts_drift = read(WEB_FACTS_DRIFT)
692 web_facts_generated = read(WEB_FACTS_GENERATED)
693 readme_md = read(README_MD)
694 config_example_toml = read(CONFIG_EXAMPLE_TOML)
695 tui_provider_readiness_rs = read(TUI_PROVIDER_READINESS_RS)
696 tui_lib_rs = read(TUI_LIB_RS)
697
698 variant_to_id = provider_kind_ids(config_rs)
699 canonical_ids = set(variant_to_id.values())
700 selectable_provider_ids = provider_kind_catalog_ids(
701 provider_kind_rs, variant_to_id
702 )
703 live_api_provider_ids = set(api_provider_ids(tui_config_rs).values())
704 public_provider_ids = canonical_ids - LEGACY_PROVIDER_TOMBSTONE_IDS
705 expected_tables = {
706 provider_table_name(provider_id) for provider_id in public_provider_ids
707 }
708 runtime_tables = expected_tables | LEGACY_PROVIDER_TOMBSTONE_TABLES
709
710 errors: list[str] = []
711 errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids)
712 errors += report_provider_kind_selector_contract(provider_kind_rs)
713 errors += report_tui_catalog_contract(tui_config_rs)
714 errors += report_tombstone_runtime_contract(
715 provider_kind_rs, tui_provider_readiness_rs, tui_lib_rs
716 )
717 errors += report_set(
718 "legacy provider identities in ProviderKind::ALL",
719 set(),
720 selectable_provider_ids & LEGACY_PROVIDER_SELECTION_IDS,
721 )
722 errors += report_set(
723 "documented selectable provider IDs",
724 selectable_provider_ids,
725 documented_selectable_provider_ids(providers_md),
726 )
727 errors += report_huggingface_coverage(config_rs, tui_config_rs, providers_md)
728 errors += report_antigravity_public_contract(
729 providers_md,
730 configuration_md,
731 web_facts_lib,
732 web_facts_drift,
733 web_facts_generated,
734 readme_md,
735 config_example_toml,
736 )
737 errors += report_set(
738 "shipped provider rows",
739 public_provider_ids,
740 shipped_provider_rows(providers_md),
741 )
742 errors += report_set(
743 "provider TOML tables",
744 runtime_tables,
745 provider_tables(config_rs) - META_PROVIDER_TABLES,
746 )
747 errors += report_set(
748 "documented provider TOML tables",
749 expected_tables,
750 shipped_provider_tables(providers_md),
751 )
752 errors += report_set(
753 "static ModelRegistry rows",
754 model_registry_providers(agent_rs, variant_to_id),
755 static_registry_provider_rows(providers_md),
756 )
757
758 missing_defaults = missing_default_strings(providers_md, default_strings(tui_config_rs))
759 if missing_defaults:
760 errors.append(
761 "docs/PROVIDERS.md does not mention default strings as Markdown code spans: "
762 + ", ".join(missing_defaults)
763 )
764 except ValueError as err:
765 errors = [str(err)]
766
767 if errors:
768 print("Provider registry drift check failed:", file=sys.stderr)
769 for error in errors:
770 print(f"- {redact_sensitive_text(error)}", file=sys.stderr)
771 return 1
772
773 print("Provider registry drift check passed.")
774 return 0
775
776
777 if __name__ == "__main__":
778 raise SystemExit(main())
779
779 lines PYTHON