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,245 @@
1
+ """What a model can do, and how much help it gets: capability profiles.
2
+
3
+ A profile is {tier, pdf, vision, json_schema, context, max_tokens_param, price_in, price_out, source}:
4
+ tier frontier | mid | small - picks the guide rails with [ai] assist = "auto" (ecadlib.llm.rails)
5
+ pdf the endpoint takes a PDF as input (Claude document blocks; OpenAI file parts)
6
+ vision it takes images (page pictures when the PDF is sent as text; visual-check needs it)
7
+ json_schema it honours a JSON-schema response format (else: schema in the prompt + JSON repair)
8
+ context context window in tokens (sizes how much page text is sent)
9
+ max_tokens_param max_tokens | max_completion_tokens (OpenAI's newer models want the second)
10
+ price_in / price_out $ per million tokens when known (Claude: datasheet.PRICES; a local server is $0)
11
+ sampling it takes a temperature (unset: ecadlib.llm.sampling's table of models that reject one)
12
+
13
+ Where it comes from, later wins: CONSERVATIVE (an unknown model: small, text only, no schema, 4K context) ->
14
+ the built-in table below (best knowledge as of 2026-10; names matched without a vendor prefix such as
15
+ "openai/" or "anthropic/") -> what the endpoint reports (detect(): /models on OpenRouter, vLLM, LM Studio;
16
+ /api/show on Ollama) -> [ai.profiles."<model>"] in ecadlib.toml (always wins)."""
17
+ import json, re
18
+
19
+ TIERS = ("frontier", "mid", "small")
20
+ FIELDS = {"tier": str, "pdf": bool, "vision": bool, "json_schema": bool, "context": int, "max_tokens_param": str,
21
+ "price_in": float, "price_out": float, "sampling": bool}
22
+ CONSERVATIVE = {"tier": "small", "pdf": False, "vision": False, "json_schema": False, "context": 4096,
23
+ "max_tokens_param": "max_tokens", "price_in": None, "price_out": None}
24
+ _C = dict(tier="frontier", pdf=True, vision=True, json_schema=True, context=1_000_000)
25
+ # (regex on the bare model name, profile fields). First match wins. Tiers are judgement calls for THIS task (reading a
26
+ # package drawing and a pin table exactly); override any of them with [ai.profiles."<model>"].
27
+ BUILTIN = [
28
+ (r"claude-(haiku|sonnet|opus)-4-5", dict(_C, context=200_000)),
29
+ (r"claude-.*", _C),
30
+ # OpenAI's 2026 names (context and PDF input assumed as for gpt-5; correct with [ai.profiles] if wrong)
31
+ (r"gpt-[56](\.\d+)?-luna.*", dict(tier="small", pdf=True, vision=True, json_schema=True, context=400_000,
32
+ max_tokens_param="max_completion_tokens")),
33
+ (r"gpt-[56](\.\d+)?-terra.*", dict(tier="mid", pdf=True, vision=True, json_schema=True, context=400_000,
34
+ max_tokens_param="max_completion_tokens")),
35
+ (r"gpt-6.*|gpt-5(\.\d+)?-sol.*", dict(tier="frontier", pdf=True, vision=True, json_schema=True, context=400_000,
36
+ max_tokens_param="max_completion_tokens")),
37
+ (r"gpt-5(\.\d+)?-nano.*", dict(tier="small", pdf=True, vision=True, json_schema=True, context=400_000,
38
+ max_tokens_param="max_completion_tokens")),
39
+ (r"gpt-5(\.\d+)?-mini.*", dict(tier="mid", pdf=True, vision=True, json_schema=True, context=400_000,
40
+ max_tokens_param="max_completion_tokens")),
41
+ (r"gpt-5.*", dict(tier="frontier", pdf=True, vision=True, json_schema=True, context=400_000,
42
+ max_tokens_param="max_completion_tokens")),
43
+ (r"o[34](-pro)?(-\d.*)?", dict(tier="frontier", pdf=True, vision=True, json_schema=True, context=200_000,
44
+ max_tokens_param="max_completion_tokens")),
45
+ (r"o4-mini.*", dict(tier="mid", pdf=True, vision=True, json_schema=True, context=200_000,
46
+ max_tokens_param="max_completion_tokens")),
47
+ (r"gpt-4\.1-nano.*|gpt-4o-mini.*", dict(tier="small", pdf=True, vision=True, json_schema=True, context=128_000)),
48
+ (r"gpt-4\.1.*|gpt-4o.*", dict(tier="mid", pdf=True, vision=True, json_schema=True, context=128_000)),
49
+ (r"gemini-[\d.]+-(pro|argon).*", dict(tier="frontier", pdf=False, vision=True, json_schema=True, context=1_000_000)),
50
+ (r"gemini-[\d.]+-flash-lite.*", dict(tier="small", pdf=False, vision=True, json_schema=True, context=1_000_000)),
51
+ (r"gemini-.*flash.*", dict(tier="mid", pdf=False, vision=True, json_schema=True, context=1_000_000)),
52
+ (r"deepseek-(chat|reasoner|v3.*|r1)", dict(tier="mid", pdf=False, vision=False, json_schema=False, context=64_000)),
53
+ (r"deepseek-flash.*", dict(tier="mid", pdf=False, vision=True, json_schema=False, context=64_000)),
54
+ (r"deepseek-v4.*", dict(tier="mid", pdf=False, vision=False, json_schema=False, context=64_000)),
55
+ ]
56
+ # List prices, $ per million tokens (input, output), standard tier, prompts up to 200K, for models called directly
57
+ # (not through OpenRouter, whose /models reports its own). Checked 2026-10-16 against benchlm.ai and promptunit.ai
58
+ # (third-party price trackers); vendors change these, so [ai.profiles."<model>"] price_in / price_out wins.
59
+ # Claude's prices are datasheet.PRICES. Known changes: gemini-4-argon $4/$20 from 2026-12-01, gemini-3.8-flash
60
+ # $1.50/$7.50 from 2027-01-01; DeepSeek bills half these rates off-peak.
61
+ PRICES = {
62
+ "gpt-6-astra": (10.0, 50.0), "gpt-6.1-sol": (2.0, 10.0), "gpt-6-sol": (2.0, 10.0), "gpt-6-luna": (0.10, 0.50),
63
+ "gpt-5.6-terra": (2.0, 12.0), "gpt-5.6-luna": (0.20, 1.20), "gpt-5.5": (5.0, 30.0), "gpt-5.4": (2.50, 15.0),
64
+ "gpt-5.4-mini": (0.75, 4.50), "gpt-5.4-nano": (0.20, 1.25), "gpt-5.2": (1.75, 14.0), "gpt-5.1": (1.25, 10.0),
65
+ "gpt-5": (1.25, 10.0), "gpt-5-mini": (0.25, 2.0), "gpt-5-nano": (0.05, 0.40),
66
+ "gemini-4-argon": (2.0, 10.0), "gemini-3.1-pro-preview": (2.0, 12.0), "gemini-3.8-flash": (0.75, 3.75),
67
+ "gemini-3.5-flash": (1.50, 9.0), "gemini-3-flash-preview": (0.50, 3.0), "gemini-3.1-flash-lite": (0.25, 1.50),
68
+ "gemini-2.5-pro": (1.25, 10.0), "gemini-2.5-flash": (0.30, 2.50), "gemini-2.5-flash-lite": (0.10, 0.40),
69
+ "deepseek-v4-pro": (1.32, 3.96), "deepseek-flash": (0.30, 1.20)}
70
+ VISION_NAME = re.compile(r"vl\b|vl[-:]|vision|llava|pixtral|minicpm-v|gemma-?3|qwen2\.5-?vl|llama-?4|moondream|-v\b", re.I)
71
+ LOCAL_FAMILY = re.compile(r"llama|llava|pixtral|minicpm|moondream|qwen|mistral|mixtral|gemma|phi|deepseek|granite|command-r|olmo|smollm|nemotron|yi|glm", re.I)
72
+ SIZE = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*b\b", re.I)
73
+
74
+
75
+ def bare(model):
76
+ """'openai/gpt-5' -> 'gpt-5', 'anthropic/claude-opus-5-5' -> 'claude-opus-5-5', 'local:qwen2.5:7b' handled by the
77
+ caller (ecadlib.llm.split_ref); 'hf.co/x/y:Q4' -> 'y:q4'. Lower case."""
78
+ return str(model or "").rsplit("/", 1)[-1].lower()
79
+
80
+
81
+ def builtin(model):
82
+ """-> (fields, source) from the table, or a guess for local open-weight families by size, or ({}, None)."""
83
+ m = bare(model)
84
+ for rx, prof in BUILTIN:
85
+ if re.fullmatch(rx, m):
86
+ got = dict(prof)
87
+ if m in PRICES:
88
+ got["price_in"], got["price_out"] = PRICES[m]
89
+ return got, f"built-in ({rx})"
90
+ if LOCAL_FAMILY.search(m):
91
+ sz = SIZE.search(m.replace(":", " ").replace("-", " "))
92
+ b = float(sz.group(1)) if sz else None
93
+ tier = "mid" if b and b >= 60 else "small"
94
+ return ({"tier": tier, "vision": bool(VISION_NAME.search(m)), "json_schema": True, "pdf": False,
95
+ "context": 8192}, f"built-in (open-weight family, {('%gB' % b) if b else 'size unknown'} -> {tier})")
96
+ return {}, None
97
+
98
+
99
+ def profile(model, provider="anthropic", rules=None, detected=None):
100
+ """The profile for `model` (a bare model id, without an endpoint prefix)."""
101
+ p, src = dict(CONSERVATIVE), ["default (unknown model: conservative)"]
102
+ got, s = builtin(model)
103
+ if provider == "anthropic" and not got: # any Claude id the table does not know yet
104
+ got, s = dict(_C), "built-in (anthropic)"
105
+ if got:
106
+ p.update(got)
107
+ src = [s]
108
+ if detected:
109
+ p.update({k: v for k, v in detected.items() if k in FIELDS and v is not None})
110
+ src.append("detected: " + ", ".join(sorted(k for k in detected if k in FIELDS and detected[k] is not None)))
111
+ over = (((rules or {}).get("ai") or {}).get("profiles") or {}) if isinstance(rules, dict) else {}
112
+ o = over.get(model) or over.get(bare(model)) or {}
113
+ if o:
114
+ p.update({k: v for k, v in o.items() if k in FIELDS})
115
+ src.append("ecadlib.toml [ai.profiles] (" + ", ".join(sorted(o)) + ")")
116
+ p["source"] = "; ".join(src)
117
+ return p
118
+
119
+
120
+ def check_field(k, v):
121
+ """Validation message for one [ai.profiles] key, or None."""
122
+ if k not in FIELDS:
123
+ return f"is not a known key; known: {', '.join(FIELDS)}"
124
+ if k == "tier" and v not in TIERS:
125
+ return f"= {v!r}: must be frontier, mid or small"
126
+ if k == "max_tokens_param" and v not in ("max_tokens", "max_completion_tokens"):
127
+ return "must be max_tokens or max_completion_tokens"
128
+ t = FIELDS[k]
129
+ if t is bool and not isinstance(v, bool):
130
+ return "must be true or false"
131
+ if t is int and (not isinstance(v, int) or isinstance(v, bool) or not 512 <= v <= 10_000_000):
132
+ return "must be a whole number of tokens from 512 to 10000000"
133
+ if t is float and (not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0):
134
+ return "must be a number of 0 or more ($ per million tokens)"
135
+ return None
136
+
137
+
138
+ # ---------------------------------------------------------------- cheap auto-detection (openai-compatible servers)
139
+ _DETECTED = {}
140
+
141
+
142
+ def _num(v):
143
+ try:
144
+ return float(v)
145
+ except (TypeError, ValueError):
146
+ return None
147
+
148
+
149
+ def from_models_entry(e):
150
+ """One entry of GET /models (OpenRouter, vLLM, LM Studio's /api/v0/models, ...) -> profile fields it states."""
151
+ out = {}
152
+ ctx = e.get("context_length") or e.get("max_model_len") or e.get("max_context_length") or \
153
+ (e.get("top_provider") or {}).get("context_length")
154
+ if isinstance(ctx, (int, float)) and ctx > 0:
155
+ out["context"] = int(ctx)
156
+ mods = (e.get("architecture") or {}).get("input_modalities")
157
+ if isinstance(mods, list):
158
+ out["vision"] = "image" in mods
159
+ out["pdf"] = "file" in mods
160
+ if e.get("type") in ("vlm", "llm"): # LM Studio
161
+ out["vision"] = e["type"] == "vlm"
162
+ sp = e.get("supported_parameters")
163
+ if isinstance(sp, list):
164
+ out["json_schema"] = "structured_outputs" in sp or "response_format" in sp
165
+ pr = e.get("pricing") or {}
166
+ pin, pout = _num(pr.get("prompt")), _num(pr.get("completion"))
167
+ if pin is not None and pout is not None and pin >= 0 and pout >= 0:
168
+ out["price_in"], out["price_out"] = round(pin * 1e6, 6), round(pout * 1e6, 6)
169
+ return out
170
+
171
+
172
+ def from_ollama_show(j):
173
+ """POST /api/show -> profile fields: capabilities ['completion', 'vision', ...], model_info *.context_length."""
174
+ out = {}
175
+ caps = j.get("capabilities")
176
+ if isinstance(caps, list):
177
+ out["vision"] = "vision" in caps
178
+ for k, v in (j.get("model_info") or {}).items():
179
+ if k.endswith(".context_length") and isinstance(v, int) and v > 0:
180
+ out["context"] = v
181
+ return out
182
+
183
+
184
+ def detect(ep, key=None, get=None, post=None, timeout=5):
185
+ """Ask the endpoint about the model (cached per base_url + model). Never raises: -> (fields, note)."""
186
+ ck = (ep.get("base_url"), ep.get("model"))
187
+ if ck in _DETECTED:
188
+ return _DETECTED[ck]
189
+ import requests
190
+ get, post = get or requests.get, post or requests.post
191
+ h = {"Authorization": f"Bearer {key}"} if key and key != "no-key-needed" else {}
192
+ fields, notes = {}, []
193
+ base = ep["base_url"]
194
+ try:
195
+ r = get(base + "/models", headers=h, timeout=timeout)
196
+ data = r.json() if getattr(r, "status_code", 0) == 200 else None
197
+ rows = (data or {}).get("data") if isinstance(data, dict) else data
198
+ hit = next((e for e in rows or [] if isinstance(e, dict) and str(e.get("id")) in (ep["model"], bare(ep["model"]))), None)
199
+ if hit:
200
+ fields.update(from_models_entry(hit))
201
+ notes.append("/models")
202
+ elif rows is not None:
203
+ notes.append(f"/models does not list {ep['model']}")
204
+ except Exception as e: # unreachable, not JSON, ...: the table decides
205
+ notes.append(f"/models: {type(e).__name__}")
206
+ root = re.sub(r"/v1/?$", "", base)
207
+ if ep.get("preset") == "ollama" or ":11434" in base:
208
+ try:
209
+ r = post(root + "/api/show", json={"model": ep["model"]}, headers=h, timeout=timeout)
210
+ if getattr(r, "status_code", 0) == 200:
211
+ fields.update(from_ollama_show(r.json()))
212
+ notes.append("/api/show")
213
+ except Exception as e:
214
+ notes.append(f"/api/show: {type(e).__name__}")
215
+ if ep.get("preset") == "lmstudio" or ":1234" in base:
216
+ try:
217
+ r = get(root + "/api/v0/models/" + ep["model"], headers=h, timeout=timeout)
218
+ if getattr(r, "status_code", 0) == 200:
219
+ fields.update(from_models_entry(r.json()))
220
+ notes.append("/api/v0/models")
221
+ except Exception as e:
222
+ notes.append(f"/api/v0/models: {type(e).__name__}")
223
+ _DETECTED[ck] = (fields, "; ".join(notes))
224
+ return _DETECTED[ck]
225
+
226
+
227
+ def resolve(ep, rules=None, key=None, get=None, post=None):
228
+ """Profile for an endpoint dict (ecadlib.llm.endpoint), with detection for openai-compatible endpoints when
229
+ [ai] detect is on. Anthropic endpoints are never probed (their table entry is enough; nothing extra is sent)."""
230
+ det, note = (None, "")
231
+ if ep["provider"] == "openai-compatible" and ep.get("detect", True):
232
+ det, note = detect(ep, key, get, post)
233
+ p = profile(ep["model"], ep["provider"], rules, det)
234
+ if note:
235
+ p["detect"] = note
236
+ return p
237
+
238
+
239
+ def describe(p):
240
+ caps = [c for c in ("pdf", "vision", "json_schema") if p.get(c)]
241
+ return f"{p['tier']} tier; {', '.join(caps) or 'text only'}; context {p['context']:,} tokens ({p['source']})"
242
+
243
+
244
+ def dumps(p):
245
+ return json.dumps(p, sort_keys=True)