chipgraph 0.1.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 (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,58 @@
1
+ """LLM providers for the API runtime, and the guard every call goes through.
2
+
3
+ - `FakeProvider` (`fake`): deterministic scripted replies for tests and CI; never a network call.
4
+ - `AnthropicProvider` (`anthropic`, `anthropic-compatible`): the Anthropic Messages API, at
5
+ Anthropic or at another `base_url` (e.g. GLM); needs the extra `chipgraph[llm]`.
6
+ - `GuardedProvider`: wraps any provider with the 'nda' rule, a run `Ledger` (token and cost
7
+ accounting against a `RunBudget`) and retry with backoff (`RetryPolicy`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from chipgraph.adapters.llm._budget import (
13
+ DEFAULT_PRICES,
14
+ Ledger,
15
+ ModelPrice,
16
+ ModelUsage,
17
+ RunBudget,
18
+ Usage,
19
+ price_for,
20
+ )
21
+ from chipgraph.adapters.llm._errors import (
22
+ BudgetExceeded,
23
+ LlmError,
24
+ NdaBlocked,
25
+ ProviderConfigError,
26
+ ProviderError,
27
+ RetriesExhausted,
28
+ )
29
+ from chipgraph.adapters.llm._nda import ensure_nda_allowed
30
+ from chipgraph.adapters.llm._retry import RetryPolicy, call_with_retry, is_retryable
31
+ from chipgraph.adapters.llm.anthropic import AnthropicProvider, anthropic_compatible
32
+ from chipgraph.adapters.llm.fake import FakeProvider, count_tokens
33
+ from chipgraph.adapters.llm.guard import GuardedProvider
34
+
35
+ __all__ = [
36
+ "DEFAULT_PRICES",
37
+ "AnthropicProvider",
38
+ "BudgetExceeded",
39
+ "FakeProvider",
40
+ "GuardedProvider",
41
+ "Ledger",
42
+ "LlmError",
43
+ "ModelPrice",
44
+ "ModelUsage",
45
+ "NdaBlocked",
46
+ "ProviderConfigError",
47
+ "ProviderError",
48
+ "RetriesExhausted",
49
+ "RetryPolicy",
50
+ "RunBudget",
51
+ "Usage",
52
+ "anthropic_compatible",
53
+ "call_with_retry",
54
+ "count_tokens",
55
+ "ensure_nda_allowed",
56
+ "is_retryable",
57
+ "price_for",
58
+ ]
@@ -0,0 +1,173 @@
1
+ """Token and cost accounting for a run: prices, the `Usage` summary, and the `Ledger`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Self
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ from chipgraph.adapters.llm._errors import BudgetExceeded
12
+ from chipgraph.core.contracts import Budget
13
+ from chipgraph.core.plugin_api.types import LlmResponse
14
+
15
+
16
+ class ModelPrice(BaseModel):
17
+ """What a model costs, in USD per million input and output tokens."""
18
+
19
+ model_config = ConfigDict(extra="forbid", frozen=True)
20
+
21
+ input_per_mtok: float = Field(ge=0, description="USD per 1M input tokens.")
22
+ output_per_mtok: float = Field(ge=0, description="USD per 1M output tokens.")
23
+
24
+ def cost_usd(self, input_tokens: int, output_tokens: int) -> float:
25
+ """The cost of one call with these token counts."""
26
+ return (input_tokens * self.input_per_mtok + output_tokens * self.output_per_mtok) / 1e6
27
+
28
+
29
+ DEFAULT_PRICES: Mapping[str, ModelPrice] = {
30
+ "claude-opus-5-5": ModelPrice(input_per_mtok=4.0, output_per_mtok=20.0),
31
+ "claude-sonnet-5-5": ModelPrice(input_per_mtok=2.0, output_per_mtok=10.0),
32
+ # Haiku 5.5 is $0.10 / $0.50 up to 100k prompt tokens and $0.50 / $2.50 above; one
33
+ # flat price cannot show both, so this is the higher one: a budget cap stays safe.
34
+ "claude-haiku-5-5": ModelPrice(input_per_mtok=0.5, output_per_mtok=2.5),
35
+ "claude-opus-4-5": ModelPrice(input_per_mtok=5.0, output_per_mtok=25.0),
36
+ "claude-opus-4-1": ModelPrice(input_per_mtok=15.0, output_per_mtok=75.0),
37
+ "claude-opus-4": ModelPrice(input_per_mtok=15.0, output_per_mtok=75.0),
38
+ "claude-sonnet-4-5": ModelPrice(input_per_mtok=3.0, output_per_mtok=15.0),
39
+ "claude-sonnet-4": ModelPrice(input_per_mtok=3.0, output_per_mtok=15.0),
40
+ "claude-haiku-4-5": ModelPrice(input_per_mtok=1.0, output_per_mtok=5.0),
41
+ "claude-3-5-haiku": ModelPrice(input_per_mtok=0.8, output_per_mtok=4.0),
42
+ }
43
+ """Base list prices (no prompt caching, no batch discount) of the Claude models known when
44
+ this table was written. Newer models, GLM and self-hosted models are not listed: pass a
45
+ table of your own (e.g. `{**DEFAULT_PRICES, "glm-x": ModelPrice(...)}`) to price them."""
46
+
47
+ _DATE_SUFFIX = re.compile(r"-\d{8}$")
48
+
49
+
50
+ def price_for(model: str, prices: Mapping[str, ModelPrice]) -> ModelPrice | None:
51
+ """The price of `model`: an exact entry, else the entry without a `-YYYYMMDD` suffix."""
52
+ if model in prices:
53
+ return prices[model]
54
+ return prices.get(_DATE_SUFFIX.sub("", model))
55
+
56
+
57
+ class ModelUsage(BaseModel):
58
+ """Usage of one model within a run."""
59
+
60
+ model_config = ConfigDict(extra="forbid", frozen=True)
61
+
62
+ calls: int = Field(default=0, ge=0, description="Completed calls.")
63
+ input_tokens: int = Field(default=0, ge=0, description="Input tokens of those calls.")
64
+ output_tokens: int = Field(default=0, ge=0, description="Output tokens of those calls.")
65
+ cost_usd: float | None = Field(
66
+ default=0.0, description="Cost in USD, or None when the model has no known price."
67
+ )
68
+
69
+
70
+ class Usage(BaseModel):
71
+ """A running summary of a run's LLM usage: calls, tokens and cost, in total and per model.
72
+
73
+ `cost_usd` sums the calls whose model has a known price; `unpriced_calls` counts the
74
+ others, whose tokens are still counted.
75
+ """
76
+
77
+ model_config = ConfigDict(extra="forbid", frozen=True)
78
+
79
+ calls: int = Field(default=0, ge=0, description="Completed calls.")
80
+ input_tokens: int = Field(default=0, ge=0, description="Input tokens of all calls.")
81
+ output_tokens: int = Field(default=0, ge=0, description="Output tokens of all calls.")
82
+ cost_usd: float = Field(default=0.0, ge=0, description="Cost of the priced calls, in USD.")
83
+ unpriced_calls: int = Field(default=0, ge=0, description="Calls of models with no price.")
84
+ per_model: dict[str, ModelUsage] = Field(default={}, description="Usage by model id.")
85
+
86
+ @property
87
+ def total_tokens(self) -> int:
88
+ """Input plus output tokens."""
89
+ return self.input_tokens + self.output_tokens
90
+
91
+ def add(
92
+ self, model: str, input_tokens: int, output_tokens: int, price: ModelPrice | None
93
+ ) -> Usage:
94
+ """A new summary with one more call of `model` added."""
95
+ cost = price.cost_usd(input_tokens, output_tokens) if price is not None else None
96
+ before = self.per_model.get(model, ModelUsage())
97
+ after = ModelUsage(
98
+ calls=before.calls + 1,
99
+ input_tokens=before.input_tokens + input_tokens,
100
+ output_tokens=before.output_tokens + output_tokens,
101
+ cost_usd=None if cost is None or before.cost_usd is None else before.cost_usd + cost,
102
+ )
103
+ return Usage(
104
+ calls=self.calls + 1,
105
+ input_tokens=self.input_tokens + input_tokens,
106
+ output_tokens=self.output_tokens + output_tokens,
107
+ cost_usd=self.cost_usd + (cost or 0.0),
108
+ unpriced_calls=self.unpriced_calls + (cost is None),
109
+ per_model={**self.per_model, model: after},
110
+ )
111
+
112
+
113
+ class RunBudget(BaseModel):
114
+ """Limits for a run: total tokens and/or USD. A limit left as None is not enforced."""
115
+
116
+ model_config = ConfigDict(extra="forbid", frozen=True)
117
+
118
+ max_tokens: int | None = Field(default=None, ge=1, description="Input + output tokens.")
119
+ max_usd: float | None = Field(
120
+ default=None, gt=0, allow_inf_nan=False, description="Cost of priced calls, USD."
121
+ )
122
+
123
+ @classmethod
124
+ def from_budget(cls, budget: Budget) -> Self:
125
+ """The run budget of a rule's `Budget` (its `tokens` cap)."""
126
+ return cls(max_tokens=budget.tokens)
127
+
128
+
129
+ class Ledger:
130
+ """Accounts every response of a run against its `RunBudget`.
131
+
132
+ One ledger may be shared by several providers so that they spend one budget. The
133
+ check runs before a call starts: once the budget is spent, no further call starts.
134
+ A call already in flight is still accounted, so the last call may overshoot a limit.
135
+ Calls of unpriced models count towards `max_tokens` but not `max_usd`.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ budget: RunBudget | None = None,
141
+ prices: Mapping[str, ModelPrice] | None = None,
142
+ ) -> None:
143
+ self.budget = budget if budget is not None else RunBudget()
144
+ self.prices: Mapping[str, ModelPrice] = prices if prices is not None else DEFAULT_PRICES
145
+ self._usage = Usage()
146
+
147
+ @property
148
+ def usage(self) -> Usage:
149
+ """The usage so far."""
150
+ return self._usage
151
+
152
+ def check(self) -> None:
153
+ """Raise `BudgetExceeded` if the budget is already spent."""
154
+ max_tokens, max_usd = self.budget.max_tokens, self.budget.max_usd
155
+ used = self._usage
156
+ if max_tokens is not None and used.total_tokens >= max_tokens:
157
+ raise BudgetExceeded(
158
+ f"token budget spent: {used.total_tokens} of {max_tokens} tokens used "
159
+ f"in {used.calls} calls"
160
+ )
161
+ if max_usd is not None and used.cost_usd >= max_usd:
162
+ raise BudgetExceeded(
163
+ f"USD budget spent: ${used.cost_usd:.4f} of ${max_usd:.4f} used "
164
+ f"in {used.calls} calls"
165
+ )
166
+
167
+ def record(self, response: LlmResponse) -> Usage:
168
+ """Account `response` and return the new usage."""
169
+ price = price_for(response.model, self.prices)
170
+ self._usage = self._usage.add(
171
+ response.model, response.input_tokens, response.output_tokens, price
172
+ )
173
+ return self._usage
@@ -0,0 +1,50 @@
1
+ """Errors raised by LLM providers and by the `GuardedProvider` wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LlmError(Exception):
7
+ """Base class of every error raised by the LLM adapters."""
8
+
9
+
10
+ class ProviderConfigError(LlmError):
11
+ """A provider cannot run as configured: missing extra, missing key variable, bad option."""
12
+
13
+
14
+ class ProviderError(LlmError):
15
+ """A provider call failed.
16
+
17
+ `retryable` is true for transient failures only (HTTP 429 and 5xx, timeouts,
18
+ connection errors); a 4xx other than 429 is never retryable. `retry_after_s` carries
19
+ the server's `retry-after` hint, if it sent one.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ *,
26
+ retryable: bool,
27
+ status: int | None = None,
28
+ retry_after_s: float | None = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.retryable = retryable
32
+ self.status = status
33
+ self.retry_after_s = retry_after_s
34
+
35
+
36
+ class RetriesExhausted(LlmError):
37
+ """Every try of a call failed with a retryable error; `last_error` is the final one."""
38
+
39
+ def __init__(self, message: str, *, tries: int, last_error: BaseException) -> None:
40
+ super().__init__(message)
41
+ self.tries = tries
42
+ self.last_error = last_error
43
+
44
+
45
+ class NdaBlocked(LlmError):
46
+ """A request labeled 'nda' was refused before any call, because the model is not local."""
47
+
48
+
49
+ class BudgetExceeded(LlmError):
50
+ """The run's token or USD budget is spent; the call was not started."""
@@ -0,0 +1,30 @@
1
+ """The 'nda' rule: data labeled 'nda' only ever goes to a local (self-hosted) model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chipgraph.adapters.llm._errors import NdaBlocked
6
+ from chipgraph.core.config.models import DataCfg
7
+ from chipgraph.core.plugin_api.types import LlmRequest
8
+
9
+
10
+ def ensure_nda_allowed(
11
+ provider: str, local: bool, request: LlmRequest, data: DataCfg | None = None
12
+ ) -> None:
13
+ """Raise `NdaBlocked` if `request` carries 'nda' and may not go to `provider`.
14
+
15
+ A request labeled 'nda' is allowed only when the provider is local and, when a
16
+ profile's `data` section is given, its `nda_model` is 'local' (DESIGN 9, 12.6).
17
+ """
18
+ if "nda" not in request.labels:
19
+ return
20
+ if not local:
21
+ raise NdaBlocked(
22
+ f"request is labeled 'nda' and provider {provider!r} is not local; 'nda' data "
23
+ "only goes to a self-hosted model (set local: 'true' on such a provider)"
24
+ )
25
+ if data is not None and data.nda_model != "local":
26
+ raise NdaBlocked(
27
+ f"request is labeled 'nda' and the profile sets data.nda_model to "
28
+ f"{data.nda_model!r}; set it to 'local' to send 'nda' data to local provider "
29
+ f"{provider!r}"
30
+ )
@@ -0,0 +1,85 @@
1
+ """Retry with exponential backoff and jitter, for transient provider failures only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Self
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
11
+
12
+ from chipgraph.adapters.llm._errors import ProviderError, RetriesExhausted
13
+
14
+ Sleep = Callable[[float], Awaitable[None]]
15
+ """An async sleep, `asyncio.sleep` by default; injectable for tests."""
16
+
17
+ Rand = Callable[[], float]
18
+ """A uniform random number in [0, 1), `random.random` by default; injectable for tests."""
19
+
20
+
21
+ class RetryPolicy(BaseModel):
22
+ """How many times to try a call and how long to wait between tries.
23
+
24
+ The wait before retry `n` (1-based) is drawn from `[c/2, c]` with
25
+ `c = min(max_delay_s, base_delay_s * 2 ** (n - 1))` ("equal jitter"), and never less
26
+ than the server's `retry-after` hint (itself capped at `max_delay_s`).
27
+ """
28
+
29
+ model_config = ConfigDict(extra="forbid", frozen=True)
30
+
31
+ max_tries: int = Field(default=4, ge=1, description="Tries in total, the first included.")
32
+ base_delay_s: float = Field(default=1.0, ge=0, description="Ceiling of the first wait.")
33
+ max_delay_s: float = Field(default=30.0, ge=0, description="Largest wait between tries.")
34
+
35
+ @model_validator(mode="after")
36
+ def _check_delays(self) -> Self:
37
+ if self.max_delay_s < self.base_delay_s:
38
+ raise ValueError("max_delay_s must be at least base_delay_s")
39
+ return self
40
+
41
+ def delay_s(self, retry: int, rand: float, retry_after_s: float | None = None) -> float:
42
+ """The wait before retry number `retry` (1-based), given a random draw `rand`."""
43
+ ceiling = min(self.max_delay_s, self.base_delay_s * 2.0 ** (retry - 1))
44
+ delay = ceiling / 2 + rand * ceiling / 2
45
+ if retry_after_s is not None:
46
+ delay = max(delay, min(retry_after_s, self.max_delay_s))
47
+ return delay
48
+
49
+
50
+ def is_retryable(exc: BaseException) -> bool:
51
+ """True for transient failures: a retryable `ProviderError`, a timeout, a lost connection."""
52
+ if isinstance(exc, ProviderError):
53
+ return exc.retryable
54
+ return isinstance(exc, TimeoutError | ConnectionError)
55
+
56
+
57
+ async def call_with_retry[T](
58
+ call: Callable[[], Awaitable[T]],
59
+ policy: RetryPolicy,
60
+ *,
61
+ sleep: Sleep = asyncio.sleep,
62
+ rand: Rand = random.random,
63
+ before_try: Callable[[], None] | None = None,
64
+ ) -> T:
65
+ """Await `call()`, retrying transient failures under `policy`.
66
+
67
+ `before_try` runs before every try (e.g. a budget check) and may raise to stop.
68
+ A non-retryable error propagates at once; when every try failed with a retryable
69
+ error, `RetriesExhausted` is raised from the last one.
70
+ """
71
+ for attempt in range(1, policy.max_tries + 1):
72
+ if before_try is not None:
73
+ before_try()
74
+ try:
75
+ return await call()
76
+ except Exception as exc:
77
+ if not is_retryable(exc):
78
+ raise
79
+ if attempt == policy.max_tries:
80
+ raise RetriesExhausted(
81
+ f"gave up after {attempt} tries: {exc}", tries=attempt, last_error=exc
82
+ ) from exc
83
+ retry_after = exc.retry_after_s if isinstance(exc, ProviderError) else None
84
+ await sleep(policy.delay_s(attempt, rand(), retry_after))
85
+ raise AssertionError("unreachable: max_tries >= 1") # pragma: no cover
@@ -0,0 +1,247 @@
1
+ """`AnthropicProvider`: the Anthropic Messages API, for `anthropic` and `anthropic-compatible`.
2
+
3
+ `anthropic-compatible` is the same Messages API served at another `base_url`, e.g. GLM's
4
+ Anthropic-compatible endpoint or a self-hosted gateway (DECISIONS D24). The SDK is the
5
+ optional extra `chipgraph[llm]` and is imported only when the first call is made.
6
+
7
+ Configuration comes from `models.providers.<name>` in the profile, all values strings:
8
+
9
+ - `base_url`: the API endpoint; required for `anthropic-compatible`. For `anthropic` it
10
+ defaults to Anthropic's own endpoint (the `ANTHROPIC_BASE_URL` variable is not read).
11
+ - `api_key_env`: the *name* of the environment variable holding the key, never the key
12
+ itself. Defaults to `ANTHROPIC_API_KEY` for `anthropic`; required for
13
+ `anthropic-compatible`, so an Anthropic key is never sent to another endpoint by default.
14
+ - `auth`: `x-api-key` (default) sends the key as `x-api-key`; `bearer` as
15
+ `Authorization: Bearer`.
16
+ - `local`: `"true"` only for a self-hosted endpoint; only then may it see 'nda' data.
17
+ - `timeout_s`: request timeout in seconds (default 600).
18
+
19
+ The SDK's own retries are turned off: retry belongs to `GuardedProvider`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib
25
+ import os
26
+ import re
27
+ from collections.abc import Mapping
28
+ from types import ModuleType
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from chipgraph.adapters.llm._errors import LlmError, ProviderConfigError, ProviderError
32
+ from chipgraph.adapters.llm._nda import ensure_nda_allowed
33
+ from chipgraph.core.config.models import ModelsCfg
34
+ from chipgraph.core.plugin_api.types import LlmRequest, LlmResponse
35
+
36
+ if TYPE_CHECKING:
37
+ import httpx2
38
+
39
+ ANTHROPIC_BASE_URL = "https://api.anthropic.com"
40
+ DEFAULT_KEY_ENV = "ANTHROPIC_API_KEY"
41
+ DEFAULT_TIMEOUT_S = 600.0
42
+
43
+ _KEYS = frozenset({"base_url", "api_key_env", "auth", "local", "timeout_s"})
44
+ _ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
45
+ _BOOLS = {"true": True, "false": False}
46
+ _AUTHS = frozenset({"x-api-key", "bearer"})
47
+
48
+
49
+ def _import_sdk() -> ModuleType:
50
+ try:
51
+ return importlib.import_module("anthropic")
52
+ except ImportError as exc:
53
+ raise ProviderConfigError(
54
+ "the anthropic provider needs the 'anthropic' package, which is not installed; "
55
+ "install the extra: pip install 'chipgraph[llm]' (or uv sync --extra llm)"
56
+ ) from exc
57
+
58
+
59
+ class AnthropicProvider:
60
+ """Completes `LlmRequest`s through the Anthropic Messages API (see the module docstring).
61
+
62
+ Construction only validates the configuration; the SDK import, the `base_url` and key
63
+ requirements are checked at the first `complete`, so an unconfigured instance (as the
64
+ plugin registry makes) is harmless. `env` defaults to `os.environ`; `http_client` is
65
+ passed to the SDK (tests inject one with a mock transport).
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ config: Mapping[str, str] | None = None,
71
+ *,
72
+ name: str = "anthropic",
73
+ compatible: bool = False,
74
+ env: Mapping[str, str] | None = None,
75
+ http_client: httpx2.AsyncClient | None = None,
76
+ ) -> None:
77
+ config = dict(config or {})
78
+ unknown = sorted(set(config) - _KEYS)
79
+ if unknown:
80
+ raise ProviderConfigError(
81
+ f"provider {name!r}: unknown option(s) {unknown}; known: {sorted(_KEYS)}"
82
+ )
83
+ self.name = name
84
+ self.compatible = compatible
85
+ self.base_url = config.get("base_url") or None
86
+ key_env = config.get("api_key_env") or (None if compatible else DEFAULT_KEY_ENV)
87
+ if key_env is not None and not _ENV_NAME.fullmatch(key_env):
88
+ # Never echo the value: it may be a key pasted in by mistake.
89
+ raise ProviderConfigError(
90
+ f"provider {name!r}: api_key_env must be the name of an environment "
91
+ "variable (letters, digits, '_'), not the key itself"
92
+ )
93
+ self.api_key_env = key_env
94
+ self.auth = config.get("auth", "x-api-key")
95
+ if self.auth not in _AUTHS:
96
+ raise ProviderConfigError(
97
+ f"provider {name!r}: auth must be one of {sorted(_AUTHS)}, got {self.auth!r}"
98
+ )
99
+ local = config.get("local", "false").strip().lower()
100
+ if local not in _BOOLS:
101
+ raise ProviderConfigError(
102
+ f"provider {name!r}: local must be 'true' or 'false', got {local!r}"
103
+ )
104
+ self.local = _BOOLS[local]
105
+ try:
106
+ self.timeout_s = float(config.get("timeout_s", DEFAULT_TIMEOUT_S))
107
+ except ValueError:
108
+ raise ProviderConfigError(
109
+ f"provider {name!r}: timeout_s must be a number of seconds"
110
+ ) from None
111
+ self._env = env
112
+ self._http_client = http_client
113
+ self._sdk: ModuleType | None = None
114
+ self._client: Any = None
115
+
116
+ @classmethod
117
+ def from_models(
118
+ cls,
119
+ models: ModelsCfg,
120
+ name: str,
121
+ *,
122
+ compatible: bool | None = None,
123
+ env: Mapping[str, str] | None = None,
124
+ http_client: httpx2.AsyncClient | None = None,
125
+ ) -> AnthropicProvider:
126
+ """The provider configured by `models.providers[name]`.
127
+
128
+ `compatible` defaults to true for any name other than 'anthropic'.
129
+ """
130
+ return cls(
131
+ models.providers.get(name, {}),
132
+ name=name,
133
+ compatible=name != "anthropic" if compatible is None else compatible,
134
+ env=env,
135
+ http_client=http_client,
136
+ )
137
+
138
+ def _get_client(self) -> tuple[ModuleType, Any]:
139
+ if self._client is not None and self._sdk is not None:
140
+ return self._sdk, self._client
141
+ sdk = _import_sdk()
142
+ where = f"models.providers.{self.name}"
143
+ if self.compatible and self.base_url is None:
144
+ raise ProviderConfigError(
145
+ f"provider {self.name!r}: anthropic-compatible needs {where}.base_url"
146
+ )
147
+ if self.api_key_env is None:
148
+ raise ProviderConfigError(
149
+ f"provider {self.name!r}: set {where}.api_key_env to the name of the "
150
+ "environment variable holding the API key"
151
+ )
152
+ env = self._env if self._env is not None else os.environ
153
+ key = env.get(self.api_key_env, "")
154
+ if not key:
155
+ raise ProviderConfigError(
156
+ f"provider {self.name!r}: environment variable {self.api_key_env} is not set; "
157
+ f"it should hold the API key ({where}.api_key_env)"
158
+ )
159
+ # SDK v2 sends `api_key` as `Authorization: Bearer`, v1 as `x-api-key`. Set the header
160
+ # explicitly (and drop the Bearer one) so `auth = "x-api-key"` is the same on the wire
161
+ # under both; `auth_token` is Bearer in both.
162
+ credential: dict[str, Any] = (
163
+ {"api_key": key, "default_headers": {"x-api-key": key, "Authorization": sdk.Omit()}}
164
+ if self.auth == "x-api-key"
165
+ else {"auth_token": key}
166
+ )
167
+ self._client = sdk.AsyncAnthropic(
168
+ **credential,
169
+ base_url=self.base_url or ANTHROPIC_BASE_URL,
170
+ timeout=self.timeout_s,
171
+ max_retries=0,
172
+ http_client=self._http_client,
173
+ )
174
+ self._sdk = sdk
175
+ return sdk, self._client
176
+
177
+ async def complete(self, request: LlmRequest) -> LlmResponse:
178
+ ensure_nda_allowed(self.name, self.local, request)
179
+ system = "\n\n".join(m.content for m in request.messages if m.role == "system")
180
+ messages = [
181
+ {"role": m.role, "content": m.content} for m in request.messages if m.role != "system"
182
+ ]
183
+ if not messages:
184
+ raise LlmError(f"provider {self.name!r}: a request needs a user or assistant message")
185
+ sdk, client = self._get_client()
186
+ kwargs: dict[str, Any] = {
187
+ "model": request.model,
188
+ "max_tokens": request.max_tokens,
189
+ "messages": messages,
190
+ }
191
+ if system:
192
+ kwargs["system"] = system
193
+ try:
194
+ message = await client.messages.create(**kwargs)
195
+ except sdk.APIStatusError as exc:
196
+ status = exc.status_code
197
+ raise ProviderError(
198
+ f"provider {self.name!r}: HTTP {status}: {exc.message}",
199
+ retryable=status == 429 or status >= 500,
200
+ status=status,
201
+ retry_after_s=_retry_after(exc.response.headers.get("retry-after")),
202
+ ) from exc
203
+ except sdk.APIConnectionError as exc: # includes APITimeoutError
204
+ raise ProviderError(
205
+ f"provider {self.name!r}: {type(exc).__name__}: {exc.message}", retryable=True
206
+ ) from exc
207
+ except sdk.AnthropicError as exc:
208
+ raise ProviderError(f"provider {self.name!r}: {exc}", retryable=False) from exc
209
+ return _to_response(message, request.model)
210
+
211
+
212
+ def _retry_after(value: str | None) -> float | None:
213
+ if value is None:
214
+ return None
215
+ try:
216
+ seconds = float(value)
217
+ except ValueError:
218
+ return None # an HTTP date: ignored, the backoff applies
219
+ return seconds if seconds >= 0 else None
220
+
221
+
222
+ def _to_response(message: Any, requested_model: str) -> LlmResponse:
223
+ text = "".join(block.text for block in message.content if block.type == "text")
224
+ usage = message.usage
225
+ # Cache writes and reads are input tokens too; count them all against the budget.
226
+ input_tokens = (
227
+ usage.input_tokens
228
+ + (usage.cache_creation_input_tokens or 0)
229
+ + (usage.cache_read_input_tokens or 0)
230
+ )
231
+ return LlmResponse(
232
+ text=text,
233
+ input_tokens=input_tokens,
234
+ output_tokens=usage.output_tokens,
235
+ model=message.model or requested_model,
236
+ )
237
+
238
+
239
+ def anthropic_compatible(
240
+ config: Mapping[str, str] | None = None,
241
+ *,
242
+ name: str = "anthropic-compatible",
243
+ env: Mapping[str, str] | None = None,
244
+ http_client: httpx2.AsyncClient | None = None,
245
+ ) -> AnthropicProvider:
246
+ """An `AnthropicProvider` for an Anthropic-compatible endpoint (the entry-point factory)."""
247
+ return AnthropicProvider(config, name=name, compatible=True, env=env, http_client=http_client)