ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,276 @@
1
+ """Bring your own model: which provider, model, endpoint and key every AI feature uses.
2
+
3
+ Two providers:
4
+ anthropic Claude through the Messages API (datasheet.Client, unchanged: PDF document blocks, structured
5
+ outputs, Message Batches, prompt caching, server-side refusal fallback). The default.
6
+ openai-compatible any /v1/chat/completions server: OpenAI, OpenRouter, Google's OpenAI-compatible Gemini endpoint,
7
+ and local servers (Ollama, LM Studio, vLLM, llama.cpp). base_url + the NAME of the env var holding
8
+ the key; a local server needs no key. ecadlib.llm.openai_compat translates our Messages-shaped
9
+ requests and answers, so everything after the call (checks, cache, cost ledger) is shared.
10
+
11
+ Settings (ecadlib.toml), all optional - with no [ai] table everything is exactly as before (Claude Opus 5.5):
12
+
13
+ [ai]
14
+ provider = "anthropic" # anthropic | openai-compatible | a preset: openai, openrouter, gemini, ollama,
15
+ # lmstudio, vllm, llamacpp (preset = openai-compatible + its usual base_url / key var)
16
+ model = "" # "" = [datasheet] model (claude-opus-5-5)
17
+ base_url = "" # openai-compatible only; "" = the preset's
18
+ api_key_env = "" # NAME of the env var with the key ("" = the preset's; none for local servers)
19
+ assist = "auto" # guide rails: auto (by the model's tier) | off | full (ecadlib.llm.rails)
20
+ feedback_retry = true # one retry that feeds datasheet's exact check errors back (every model, Claude too)
21
+ detect = true # ask an openai-compatible server what the model can do (context, vision, price)
22
+ temperature = "auto" # auto | off | 0..2: temperature 0 + seed where the model takes them (llm.sampling)
23
+ [ai.profiles."qwen2.5:7b"] # override what ecadlib assumes about a model (ecadlib.llm.profiles)
24
+ tier = "small" # frontier | mid | small
25
+ [ai.endpoints.local] # named endpoints for a mixed ladder: [datasheet] ladder = ["local:qwen2.5:7b",
26
+ provider = "ollama" # "claude-opus-5-5"] tries the local model first and escalates to Claude
27
+
28
+ [datasheet] and [agent.proof] may set provider / base_url / api_key_env / model / assist of their own; empty = [ai].
29
+ Keys are read from environment variables only and never written anywhere. Nothing here phones home: no telemetry,
30
+ no hosted service of this project; requests go only to the endpoint you configure."""
31
+ import ipaddress, os, re
32
+ from urllib.parse import urlparse
33
+
34
+ PROVIDERS = ("anthropic", "openai-compatible")
35
+ # preset name -> (base_url, key env var name); every preset is an openai-compatible endpoint
36
+ PRESETS = {"openai": ("https://api.openai.com/v1", "OPENAI_API_KEY"),
37
+ "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"),
38
+ "gemini": ("https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY"),
39
+ "ollama": ("http://localhost:11434/v1", ""),
40
+ "lmstudio": ("http://localhost:1234/v1", ""),
41
+ "vllm": ("http://localhost:8000/v1", ""),
42
+ "llamacpp": ("http://localhost:8080/v1", "")}
43
+ ASSIST = ("auto", "off", "full")
44
+ ANTHROPIC_MODEL = "claude-opus-5-5"
45
+ ANTHROPIC_KEY = "ANTHROPIC_API_KEY"
46
+ DEFAULTS = {"provider": "anthropic", "model": "", "base_url": "", "api_key_env": "", "assist": "auto",
47
+ "feedback_retry": True, "detect": True, "profiles": {}, "endpoints": {}, "temperature": "auto"}
48
+ SECTION_KEYS = ("provider", "base_url", "assist") # added to [datasheet] and [agent.proof] (empty = [ai])
49
+ MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/@+\-]{0,199}")
50
+ ENV_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
51
+ ENDPOINT_KEYS = ("provider", "base_url", "api_key_env", "assist")
52
+ NO_KEY = "no-key-needed" # a local endpoint: there is no key to send
53
+
54
+
55
+ class AIConfigError(ValueError):
56
+ pass
57
+
58
+
59
+ def is_local(url):
60
+ """localhost, loopback, private and link-local addresses, *.local / *.lan hosts: a server on your own network."""
61
+ host = (urlparse(url or "").hostname or "").lower()
62
+ if not host:
63
+ return False
64
+ if host in ("localhost", "host.docker.internal") or host.endswith((".local", ".lan", ".internal", ".localhost")):
65
+ return True
66
+ try:
67
+ ip = ipaddress.ip_address(host)
68
+ except ValueError:
69
+ return False
70
+ return ip.is_loopback or ip.is_private or ip.is_link_local
71
+
72
+
73
+ def _ai(rules):
74
+ got = (rules or {}).get("ai") if isinstance(rules, dict) else None
75
+ return {**DEFAULTS, **{k: v for k, v in (got or {}).items() if k in DEFAULTS}}
76
+
77
+
78
+ def _section(rules, section):
79
+ r = rules if isinstance(rules, dict) else {}
80
+ if section == "proof":
81
+ return ((r.get("agent") or {}).get("proof") or {})
82
+ return r.get(section) or {}
83
+
84
+
85
+ def _norm_provider(p):
86
+ """-> (provider, preset or None)."""
87
+ p = (p or "anthropic").strip().lower()
88
+ if p in PRESETS:
89
+ return "openai-compatible", p
90
+ if p in ("openai-compatible", "openai_compatible", "openai-compat"):
91
+ return "openai-compatible", None
92
+ if p == "anthropic":
93
+ return "anthropic", None
94
+ raise AIConfigError(f"provider {p!r}: use anthropic, openai-compatible or one of {', '.join(PRESETS)}")
95
+
96
+
97
+ def split_ref(ref, rules=None):
98
+ """'local:qwen2.5:7b' -> ('local', 'qwen2.5:7b') when [ai.endpoints.local] exists, else (None, ref)."""
99
+ eps = _ai(rules).get("endpoints") or {}
100
+ head, sep, rest = str(ref or "").partition(":")
101
+ if sep and head in eps and rest:
102
+ return head, rest
103
+ return None, ref
104
+
105
+
106
+ def endpoint(rules=None, section="datasheet", model=None):
107
+ """Where a request for `section` goes: {provider, preset, base_url, key_env, model, ref, name, local, assist,
108
+ feedback_retry, detect}. `model` (an explicit --model or a ladder rung) may name an endpoint: 'local:qwen2.5:7b'.
109
+ With no [ai] table and no section overrides this is Anthropic + [datasheet] model + [datasheet] api_key_env."""
110
+ ai, sec = _ai(rules), _section(rules, section)
111
+ ds = _section(rules, "datasheet")
112
+ if section == "proof": # [agent.proof] empty = [datasheet] (as before)
113
+ sec = {k: sec.get(k) or ds.get(k) for k in set(sec) | set(ds) | {"provider", "base_url", "assist", "model",
114
+ "api_key_env"}}
115
+ name, mref = split_ref(model, rules) if model else (None, None)
116
+ if name:
117
+ ep_cfg = ai["endpoints"][name] or {}
118
+ provider, preset = _norm_provider(ep_cfg.get("provider") or "openai-compatible")
119
+ base = ep_cfg.get("base_url") or (PRESETS[preset][0] if preset else "")
120
+ key_env = ep_cfg.get("api_key_env") or (PRESETS[preset][1] if preset else "")
121
+ assist = ep_cfg.get("assist") or sec.get("assist") or ai["assist"]
122
+ else:
123
+ provider, preset = _norm_provider(sec.get("provider") or ai["provider"])
124
+ own_provider = bool(sec.get("provider"))
125
+ base = sec.get("base_url") or ("" if own_provider and sec.get("provider") != ai["provider"] else ai["base_url"]) \
126
+ or (PRESETS[preset][0] if preset else "")
127
+ sk = sec.get("api_key_env") or ""
128
+ if sk == ANTHROPIC_KEY and (provider != "anthropic" or ai["api_key_env"]):
129
+ sk = "" # [datasheet]'s default key name: [ai] api_key_env / the endpoint's decide
130
+ key_env = sk or ai["api_key_env"] or (PRESETS[preset][1] if preset else "") or \
131
+ (ANTHROPIC_KEY if provider == "anthropic" else "")
132
+ assist = sec.get("assist") or ai["assist"]
133
+ mref = model
134
+ if not mref:
135
+ sm = sec.get("model") or ""
136
+ if section in ("datasheet", "proof") and sm == ANTHROPIC_MODEL and (ai["model"] or provider != "anthropic"):
137
+ sm = "" # [datasheet] model at its default: [ai] model decides
138
+ mref = sm or ai["model"] or (ANTHROPIC_MODEL if provider == "anthropic" else "")
139
+ if not mref:
140
+ raise AIConfigError(f"no model set for provider {preset or provider}: set [ai] model (e.g. \"qwen2.5:7b\" for "
141
+ "Ollama, \"gpt-5\" for OpenAI)")
142
+ if provider == "openai-compatible" and not base:
143
+ raise AIConfigError("provider openai-compatible needs [ai] base_url (e.g. http://localhost:11434/v1)")
144
+ if assist not in ASSIST:
145
+ raise AIConfigError(f"assist = {assist!r}: use auto, off or full")
146
+ ref = f"{name}:{mref}" if name else mref
147
+ return {"provider": provider, "preset": preset, "base_url": base.rstrip("/") if base else "", "key_env": key_env,
148
+ "model": mref, "ref": ref, "name": name or (preset or provider), "local": is_local(base),
149
+ "assist": assist, "feedback_retry": bool(ai["feedback_retry"]), "detect": bool(ai["detect"])}
150
+
151
+
152
+ def key_for(ep, explicit=None, environ=None):
153
+ """The key for an endpoint, from the environment only. Anthropic: explicit, the named variable, then
154
+ ANTHROPIC_API_KEY (as datasheet.require_key always did). A local endpoint needs none (NO_KEY). Otherwise None."""
155
+ env = os.environ if environ is None else environ
156
+ if explicit:
157
+ return explicit
158
+ k = env.get(ep["key_env"]) if ep.get("key_env") else None
159
+ if ep["provider"] == "anthropic":
160
+ return k or env.get(ANTHROPIC_KEY)
161
+ if k:
162
+ return k
163
+ return NO_KEY if ep["local"] or not ep.get("key_env") else None # no key named: the server takes none
164
+
165
+
166
+ def needs_key_message(ep):
167
+ if ep["provider"] == "anthropic":
168
+ return (f"{ep['key_env']} is not set: add your Claude API key on the setup page (`ecadlib setup`) or with "
169
+ f"`ecadlib keys set {ep['key_env']}`")
170
+ name = ep['key_env'] or '[ai] api_key_env'
171
+ return (f"{name} is not set: add the key for {ep['base_url']} on the setup page (`ecadlib setup`) or with "
172
+ f"`ecadlib keys set {name}` (or point [ai] base_url at a local server, which needs none)")
173
+
174
+
175
+ # ---------------------------------------------------------------- model registry (prices / profiles of models in use)
176
+ _REG = {}
177
+
178
+
179
+ def register(ref, ep, prof):
180
+ _REG[ref] = {"endpoint": ep, "profile": prof}
181
+
182
+
183
+ def registered(ref):
184
+ return _REG.get(ref)
185
+
186
+
187
+ def price(ref):
188
+ """(input, output) $ per MTok for a model in use, or None when unknown. Claude: datasheet.PRICES. A local
189
+ endpoint: (0, 0). Others: what the profile says (rules override, or detected from OpenRouter's /models)."""
190
+ from .. import datasheet as D
191
+ if ref in D.PRICES:
192
+ return D.PRICES[ref]
193
+ r = _REG.get(ref)
194
+ if not r:
195
+ return None
196
+ if r["endpoint"]["local"]:
197
+ return (0.0, 0.0)
198
+ p = r["profile"]
199
+ if p.get("price_in") is not None and p.get("price_out") is not None:
200
+ return (float(p["price_in"]), float(p["price_out"]))
201
+ return None
202
+
203
+
204
+ def is_anthropic(ref, rules=None):
205
+ r = _REG.get(ref)
206
+ if r:
207
+ return r["endpoint"]["provider"] == "anthropic"
208
+ try:
209
+ return endpoint(rules, model=ref)["provider"] == "anthropic"
210
+ except AIConfigError:
211
+ return False
212
+
213
+
214
+ # ---------------------------------------------------------------- rules validation (called from rules.validate)
215
+ def validate(where, raw_ai, sections, err):
216
+ """[ai], [ai.profiles.*], [ai.endpoints.*] and the provider keys of [datasheet] / [agent.proof]."""
217
+ from . import profiles as PR
218
+ ai = raw_ai or {}
219
+ if not isinstance(ai, dict):
220
+ err(where, "[ai]", "must be a table")
221
+ for k, v in ai.items():
222
+ if k not in DEFAULTS:
223
+ err(where, f"ai.{k}", f"is not a known key; known in [ai]: {', '.join(DEFAULTS)}")
224
+ if k == "temperature":
225
+ from .sampling import check
226
+ if check(v):
227
+ err(where, "ai.temperature", check(v))
228
+ continue
229
+ d = DEFAULTS[k]
230
+ if isinstance(d, bool):
231
+ if not isinstance(v, bool):
232
+ err(where, f"ai.{k}", "must be true or false")
233
+ elif isinstance(d, dict):
234
+ if not isinstance(v, dict) or not all(isinstance(x, dict) for x in v.values()):
235
+ err(where, f"ai.{k}", f"must be tables: [ai.{k}.\"name\"] key = value")
236
+ elif not isinstance(v, str):
237
+ err(where, f"ai.{k}", "must be text")
238
+ for label, d in [("ai", ai)] + list(sections):
239
+ if not isinstance(d, dict):
240
+ continue
241
+ if d.get("provider"):
242
+ try:
243
+ _norm_provider(d["provider"])
244
+ except AIConfigError as e:
245
+ err(where, f"{label}.provider", f"= {d['provider']!r}: {e}")
246
+ if d.get("assist") and d["assist"] not in ASSIST:
247
+ err(where, f"{label}.assist", f"= {d['assist']!r}: must be auto (by the model's tier), off or full")
248
+ if d.get("base_url") and not re.fullmatch(r"https?://[^\s]+", str(d["base_url"])):
249
+ err(where, f"{label}.base_url", "must be an http(s):// URL such as http://localhost:11434/v1")
250
+ if d.get("base_url") and str(d["base_url"]).startswith("http://") and not is_local(d["base_url"]):
251
+ err(where, f"{label}.base_url", "plain http:// is only allowed for a server on this machine or your own "
252
+ "network (the key would travel unencrypted); use https://")
253
+ if d.get("api_key_env") and not ENV_RE.fullmatch(str(d["api_key_env"])):
254
+ err(where, f"{label}.api_key_env", "must be the NAME of an environment variable (never the key itself)")
255
+ if d.get("model") and not MODEL_RE.fullmatch(str(d["model"])):
256
+ err(where, f"{label}.model", f"= {d['model']!r}: must be a model id such as claude-opus-5-5 or qwen2.5:7b")
257
+ for n, ep in (ai.get("endpoints") or {}).items():
258
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", n):
259
+ err(where, f"ai.endpoints.{n}", "endpoint names are letters, digits, _ and - (used as name:model in a ladder)")
260
+ for k, v in ep.items():
261
+ if k not in ENDPOINT_KEYS:
262
+ err(where, f"ai.endpoints.{n}.{k}", f"is not a known key; known: {', '.join(ENDPOINT_KEYS)}")
263
+ if not isinstance(v, str):
264
+ err(where, f"ai.endpoints.{n}.{k}", "must be text")
265
+ validate(where, None, [(f"ai.endpoints.{n}", ep)], err) if ep else None
266
+ try:
267
+ p, preset = _norm_provider(ep.get("provider") or "openai-compatible")
268
+ except AIConfigError:
269
+ continue
270
+ if p == "openai-compatible" and not ep.get("base_url") and not preset:
271
+ err(where, f"ai.endpoints.{n}.base_url", "is needed for an openai-compatible endpoint without a preset")
272
+ for m, prof in (ai.get("profiles") or {}).items():
273
+ for k, v in prof.items():
274
+ msg = PR.check_field(k, v)
275
+ if msg:
276
+ err(where, f"ai.profiles.{m}.{k}", msg)
ecadlib/llm/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """`ecadlib ai`: which model, provider and guide rails the AI features use.
2
+
3
+ ecadlib ai show [--model M] [--section datasheet|proof] [--detect] [--rules FILE] [--json]
4
+ ecadlib ai rails every rail: layer (support / capability / guidance) and what it does
5
+
6
+ show prints the endpoint (provider, base_url, key variable: set or not, never the value), the capability profile
7
+ and where it came from, and the rails a read would run, by layer. --detect asks an openai-compatible server about
8
+ the model (GET /models, Ollama /api/show); without it nothing is sent anywhere."""
9
+ import argparse, json, os, sys
10
+
11
+ from . import AIConfigError, endpoint, key_for, NO_KEY, profiles as PR, rails as R
12
+
13
+
14
+ def show(rules, model=None, section="datasheet", detect=False):
15
+ ep = endpoint(rules, section, model)
16
+ if detect and ep["provider"] == "openai-compatible":
17
+ prof = PR.resolve(ep, rules, key_for(ep))
18
+ else:
19
+ prof = PR.profile(ep["model"], ep["provider"], rules)
20
+ plan = R.plan(prof, ep["assist"], ep["feedback_retry"])
21
+ k = key_for(ep)
22
+ key = "none needed" if k == NO_KEY else (f"{ep['key_env'] or 'ANTHROPIC_API_KEY'} set" if k else f"{ep['key_env']} NOT set")
23
+ return {"endpoint": {**ep, "key": key}, "profile": prof, "plan": plan, "layers": R.layers(plan)}
24
+
25
+
26
+ def main(argv=None):
27
+ ap = argparse.ArgumentParser(prog="ecadlib ai", description="the model, provider and guide rails the AI features use",
28
+ epilog="examples:\n ecadlib ai show\n ecadlib ai show --model qwen2.5:7b --detect\n"
29
+ " ecadlib ai rails", formatter_class=argparse.RawDescriptionHelpFormatter)
30
+ sub = ap.add_subparsers(dest="cmd", required=True)
31
+ s = sub.add_parser("show", help="endpoint, profile and rails for the configured (or a given) model")
32
+ s.add_argument("--model", help="a model id (or endpoint:model); default: [ai] / [datasheet] model")
33
+ s.add_argument("--section", choices=["datasheet", "proof"], default="datasheet")
34
+ s.add_argument("--detect", action="store_true", help="ask an openai-compatible server what the model can do")
35
+ s.add_argument("--rules", help="rules file (default: ecadlib.toml found from here)")
36
+ s.add_argument("--json", action="store_true")
37
+ sub.add_parser("rails", help="list every rail and its layer")
38
+ a = ap.parse_args(argv)
39
+ if a.cmd == "rails":
40
+ for layer in ("support", "capability", "guidance"):
41
+ print(f"{layer}:")
42
+ for k, (lv, what) in R.RAILS.items():
43
+ if lv == layer:
44
+ tiers = [t for t, rs in R.TIER_RAILS.items() if k in rs]
45
+ print(f" {k:<17} {what}" + (f" [tiers: {', '.join(tiers)}]" if tiers else ""))
46
+ return 0
47
+ from .. import rules as RU
48
+ rules = RU.load(a.rules)
49
+ try:
50
+ rep = show(rules, a.model, a.section, a.detect)
51
+ except AIConfigError as e:
52
+ print(f"error: {e}", file=sys.stderr)
53
+ return 2
54
+ if a.json:
55
+ print(json.dumps(rep, indent=2, default=str))
56
+ return 0
57
+ ep, p = rep["endpoint"], rep["profile"]
58
+ print(f"model {ep['model']} via {ep['name']} ({'Anthropic API' if ep['provider'] == 'anthropic' else ep['base_url']})")
59
+ print(f"key {ep['key']}")
60
+ print(f"profile {PR.describe(p)}")
61
+ if p.get("detect"):
62
+ print(f"detected {p['detect']}")
63
+ print(f"assist {ep['assist']} -> {rep['plan']['tier']} tier")
64
+ for layer, names in rep["layers"].items():
65
+ print(f"{layer:<11}{', '.join(names) or '-'}")
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -0,0 +1,214 @@
1
+ """An OpenAI-compatible /v1/chat/completions endpoint behind the same interface as datasheet.Client.
2
+
3
+ messages(body, headers) takes our Messages-API-shaped request and returns a Messages-API-shaped answer, so the
4
+ datasheet reader, visual check, cache and cost ledger need no second code path:
5
+
6
+ request system blocks -> one system message; text -> text parts; image (base64) -> image_url data URL; a PDF
7
+ document -> a file part (only when the profile says the endpoint reads PDFs; otherwise the rails turn it
8
+ into page text first: ecadlib.llm.rails); assistant turns (a feedback retry) -> their text.
9
+ output_config.format json_schema -> response_format json_schema (strict) when the profile has json_schema,
10
+ else json_object; effort, cache_control, fallbacks and other Anthropic-only fields are dropped.
11
+ answer choices[0].message.content -> one text block; finish_reason stop/length/content_filter -> stop_reason
12
+ end_turn/max_tokens/refusal (message.refusal too); usage prompt/completion tokens -> input/output tokens.
13
+
14
+ Retries 408/409/429/5xx and network errors with backoff (retry-after honoured), like datasheet.Client. A server that
15
+ rejects response_format with HTTP 400 is asked once more without it (json_object, then none), and the answer goes
16
+ through the JSON-repair rail. Every request is logged as one JSON line without the key or the image/PDF bytes.
17
+ The key is only ever sent to the configured base_url."""
18
+ import datetime, json, random, time
19
+ from pathlib import Path
20
+
21
+ import requests
22
+
23
+ RETRY_STATUS = {408, 409, 429, 500, 502, 503, 504, 529}
24
+ FINISH = {"stop": "end_turn", "length": "max_tokens", "content_filter": "refusal", "tool_calls": "tool_use"}
25
+
26
+
27
+ class OpenAICompatClient:
28
+ def __init__(self, base_url, key=None, profile=None, retries=4, backoff=2.0, max_wait=60.0, timeout=600, sleep=None,
29
+ log_file=None, say=None, post=None, model=None):
30
+ from .. import datasheet as D
31
+ self.base, self.key = base_url.rstrip("/"), key
32
+ self.model = model # the server's model id ('qwen2.5:7b' of ref 'local:qwen2.5:7b')
33
+ self.profile = dict(profile or {})
34
+ self.retries, self.backoff, self.max_wait, self.timeout = retries, backoff, max_wait, timeout
35
+ self.sleep = sleep or time.sleep
36
+ self.log_file, self.say = log_file or D._LOG["file"], say or D._LOG["say"]
37
+ self._post = post
38
+ self.format_fallback = None # set when the server rejected json_schema once
39
+ self.sampling = {} # temperature / seed for every request (llm.sampling)
40
+
41
+ # --- translation
42
+ def to_chat(self, body):
43
+ from ..datasheet import DatasheetError
44
+ msgs = []
45
+ sys_txt = "\n\n".join(b.get("text", "") for b in body.get("system") or [] if isinstance(b, dict)) \
46
+ if isinstance(body.get("system"), list) else (body.get("system") or "")
47
+ if sys_txt:
48
+ msgs.append({"role": "system", "content": sys_txt})
49
+ for m in body.get("messages") or []:
50
+ content = m.get("content")
51
+ if isinstance(content, str):
52
+ msgs.append({"role": m["role"], "content": content})
53
+ continue
54
+ if m["role"] == "assistant":
55
+ msgs.append({"role": "assistant", "content": "".join(b.get("text", "") for b in content if b.get("type") == "text")})
56
+ continue
57
+ parts = []
58
+ for b in content:
59
+ t = b.get("type")
60
+ if t == "text":
61
+ parts.append({"type": "text", "text": b["text"]})
62
+ elif t == "image":
63
+ src = b["source"]
64
+ url = src["url"] if src.get("type") == "url" else f"data:{src['media_type']};base64,{src['data']}"
65
+ parts.append({"type": "image_url", "image_url": {"url": url}})
66
+ elif t == "document":
67
+ src = b["source"]
68
+ if not self.profile.get("pdf"):
69
+ raise DatasheetError("this model does not take PDF input and the PDF was not turned into page text "
70
+ "(set [ai.profiles] pdf = true if it does)")
71
+ if src.get("type") != "base64":
72
+ raise DatasheetError("this endpoint can't fetch a PDF by URL: the datasheet must be downloaded here")
73
+ parts.append({"type": "file", "file": {"filename": "datasheet.pdf",
74
+ "file_data": f"data:application/pdf;base64,{src['data']}"}})
75
+ msgs.append({"role": m["role"], "content": parts})
76
+ out = {"model": self.model or body["model"], "messages": msgs}
77
+ mt = body.get("max_tokens")
78
+ if mt:
79
+ out[self.profile.get("max_tokens_param") or "max_tokens"] = mt
80
+ fmt = ((body.get("output_config") or {}).get("format") or {})
81
+ mode = self.format_fallback or ("json_schema" if self.profile.get("json_schema") else "json_object")
82
+ if fmt.get("type") == "json_schema" and mode == "json_schema":
83
+ out["response_format"] = {"type": "json_schema",
84
+ "json_schema": {"name": "answer", "schema": fmt["schema"], "strict": True}}
85
+ elif fmt.get("type") == "json_schema" and mode == "json_object":
86
+ out["response_format"] = {"type": "json_object"}
87
+ if fmt.get("type") == "json_schema" and mode != "json_schema" and self.profile.get("json_schema"):
88
+ # the plan relied on a schema response format the server rejected: the schema goes in the prompt instead
89
+ from .rails import schema_text
90
+ st = schema_text(fmt["schema"])
91
+ if msgs and msgs[0]["role"] == "system":
92
+ if st.strip() not in msgs[0]["content"]:
93
+ msgs[0]["content"] += st
94
+ else:
95
+ msgs.insert(0, {"role": "system", "content": st.strip()})
96
+ for k in ("temperature", "seed"):
97
+ if k in body:
98
+ out[k] = body[k]
99
+ elif k in (self.sampling or {}):
100
+ out[k] = self.sampling[k]
101
+ return out
102
+
103
+ @staticmethod
104
+ def to_message(resp, model):
105
+ ch = (resp.get("choices") or [{}])[0]
106
+ m = ch.get("message") or {}
107
+ text = m.get("content")
108
+ if isinstance(text, list): # some servers return content parts
109
+ text = "".join(p.get("text", "") for p in text if isinstance(p, dict))
110
+ stop = FINISH.get(ch.get("finish_reason"), "end_turn")
111
+ if m.get("refusal"):
112
+ stop = "refusal"
113
+ u = resp.get("usage") or {}
114
+ cached = ((u.get("prompt_tokens_details") or {}).get("cached_tokens")) or 0
115
+ return {"id": resp.get("id"), "type": "message", "role": "assistant", "model": resp.get("model") or model,
116
+ "content": [{"type": "text", "text": text or ""}], "stop_reason": stop,
117
+ "stop_details": {"type": "refusal", "category": None, "explanation": m.get("refusal")} if stop == "refusal" else None,
118
+ "usage": {"input_tokens": int(u.get("prompt_tokens") or 0) - int(cached), "cache_read_input_tokens": int(cached),
119
+ "output_tokens": int(u.get("completion_tokens") or 0)}}
120
+
121
+ # --- HTTP
122
+ def _log(self, rec):
123
+ if self.log_file:
124
+ Path(self.log_file).parent.mkdir(parents=True, exist_ok=True)
125
+ with open(self.log_file, "a", encoding="utf-8") as f:
126
+ f.write(json.dumps(rec, default=str) + "\n")
127
+
128
+ def _summary(self, chat):
129
+ def s(v):
130
+ if isinstance(v, dict):
131
+ return {k: (f"<{len(x)} chars>" if k in ("url", "file_data") and isinstance(x, str) and x.startswith("data:")
132
+ else f"<json schema>" if k == "schema" else s(x)) for k, x in v.items()}
133
+ if isinstance(v, list):
134
+ return [s(x) for x in v]
135
+ if isinstance(v, str) and len(v) > 300:
136
+ return v[:120] + f"... <{len(v)} chars>"
137
+ return v
138
+ return s(chat)
139
+
140
+ def chat(self, chat):
141
+ from ..datasheet import APIError
142
+ url = self.base + "/chat/completions"
143
+ h = {"content-type": "application/json"}
144
+ if self.key and self.key != "no-key-needed":
145
+ h["authorization"] = f"Bearer {self.key}"
146
+ post = self._post or requests.post
147
+ for attempt in range(self.retries + 1):
148
+ t0 = time.time()
149
+ rec = {"t": datetime.datetime.now().isoformat(timespec="seconds"), "method": "POST", "url": url,
150
+ "attempt": attempt + 1, "body": self._summary(chat)}
151
+ retry_after = None
152
+ try:
153
+ from ..term import waiting
154
+ with waiting(f"the model at {self.base} (model call)"):
155
+ r = post(url, headers=h, json=chat, timeout=self.timeout)
156
+ except requests.RequestException as e:
157
+ err = APIError(f"{self.base}: request failed: {type(e).__name__}: {str(e)[:200]}", retryable=True)
158
+ rec.update(error=str(err), ms=int((time.time() - t0) * 1000))
159
+ else:
160
+ try:
161
+ payload = r.json()
162
+ except ValueError:
163
+ payload = None
164
+ rec.update(status=r.status_code, ms=int((time.time() - t0) * 1000))
165
+ if r.status_code == 200 and isinstance(payload, dict) and "choices" in payload:
166
+ rec["usage"] = payload.get("usage")
167
+ self._log(rec)
168
+ return payload
169
+ e = (payload or {}).get("error") if isinstance(payload, dict) else None
170
+ emsg = (e.get("message") if isinstance(e, dict) else e) or (getattr(r, "text", "") or "")[:200]
171
+ hint = {401: " (the key was rejected: check it and the variable it is read from)",
172
+ 404: " (unknown model or endpoint: check [ai] model and base_url)",
173
+ 429: " (rate limited)"}.get(r.status_code, "")
174
+ err = APIError(f"{self.base}: HTTP {r.status_code}: {str(emsg)[:300]}{hint}", r.status_code,
175
+ (e or {}).get("type") if isinstance(e, dict) else None, r.status_code in RETRY_STATUS)
176
+ rec["error"] = str(emsg)[:300]
177
+ retry_after = (getattr(r, "headers", None) or {}).get("retry-after")
178
+ self._log(rec)
179
+ if not err.retryable or attempt == self.retries:
180
+ raise err
181
+ try:
182
+ wait = float(retry_after) if retry_after is not None else None
183
+ except ValueError:
184
+ wait = None
185
+ wait = min(self.max_wait * 5, wait) if wait is not None else min(self.max_wait, self.backoff * 2 ** attempt + random.uniform(0, 1))
186
+ if self.say:
187
+ self.say(f" model API: {err.args[0][:120]}; retry {attempt + 1}/{self.retries} in {wait:.0f}s")
188
+ self.sleep(wait)
189
+
190
+ def messages(self, body, headers=None):
191
+ """Messages-shaped body -> Messages-shaped answer (see the module docstring)."""
192
+ from ..datasheet import APIError
193
+ chat = self.to_chat(body)
194
+ try:
195
+ resp = self.chat(chat)
196
+ except APIError as e:
197
+ if e.status == 400 and "response_format" in chat and self.format_fallback != "none":
198
+ # the server does not take this response format: ask once more with a looser one (the rails repair JSON)
199
+ self.format_fallback = "json_object" if chat["response_format"]["type"] == "json_schema" else "none"
200
+ return self.messages(body, headers)
201
+ raise
202
+ msg = self.to_message(resp, body["model"])
203
+ if self.format_fallback and self.profile.get("json_schema") and msg["stop_reason"] == "end_turn":
204
+ from .rails import extract_json # no schema-constrained output: unwrap a fenced / wrapped object
205
+ try:
206
+ val, fixes = extract_json(msg["content"][0]["text"])
207
+ except ValueError:
208
+ val, fixes = None, []
209
+ if fixes and isinstance(val, dict):
210
+ msg["content"] = [{"type": "text", "text": json.dumps(val)}]
211
+ return msg
212
+
213
+ def count_tokens(self, body):
214
+ raise NotImplementedError("count_tokens is an Anthropic endpoint")