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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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)
|