briar-cli 1.1.1__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.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
briar/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""briar — terminal client for the Briar agent-orchestration API.
|
|
2
|
+
|
|
3
|
+
Stdlib-only. Importing the top-level package exposes only the version;
|
|
4
|
+
public surfaces live in `briar.cli` (entry point) and the typed
|
|
5
|
+
sub-packages (`briar.commands`, `briar.iac`, `briar.formatting`, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.1.0"
|
briar/__main__.py
ADDED
briar/_registry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Defensive `build_registry` helper.
|
|
2
|
+
|
|
3
|
+
Every plugin family in the codebase builds its `_REGISTRY` dict with
|
|
4
|
+
the same pattern: ``{x.name: x for x in (Item1(), Item2(), ...)}``.
|
|
5
|
+
That comprehension silently drops a duplicate-name collision (Python
|
|
6
|
+
dict-literal semantics: later assignment wins).
|
|
7
|
+
|
|
8
|
+
If two adapters ever claim the same `name` — say, two refactors both
|
|
9
|
+
typing ``kind = "github"`` — the registry would silently expose only
|
|
10
|
+
one. Hours of debugging.
|
|
11
|
+
|
|
12
|
+
This module's `build_registry()` does the exact same thing but raises
|
|
13
|
+
on a dup. Every registry in the codebase should use it."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Iterable, TypeVar
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_registry(items: Iterable[T], *, kind: str, name_attr: str = "name") -> dict:
|
|
24
|
+
"""Build the `{item.<name_attr>: item}` map, raising on a
|
|
25
|
+
duplicate. `kind` is the human-readable family name shown in the
|
|
26
|
+
error message ("repository provider", "tracker", "agent op", etc.)."""
|
|
27
|
+
out: dict = {}
|
|
28
|
+
for item in items:
|
|
29
|
+
key = getattr(item, name_attr, None)
|
|
30
|
+
if not key:
|
|
31
|
+
raise RuntimeError(f"build_registry({kind}): item {item!r} has empty {name_attr!r}")
|
|
32
|
+
if key in out:
|
|
33
|
+
existing = out[key]
|
|
34
|
+
raise RuntimeError(f"build_registry({kind}): duplicate {name_attr}={key!r} (already registered: {existing!r}, second: {item!r})")
|
|
35
|
+
out[key] = item
|
|
36
|
+
return out
|
briar/agent/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Autonomous agent runtime.
|
|
2
|
+
|
|
3
|
+
`briar agent prfix --company X [--pr N]` invokes an Anthropic-API-driven
|
|
4
|
+
loop that reads PR review threads, applies minimum-correct fixes, and
|
|
5
|
+
commits + pushes follow-ups as the human GitHub identity. The system
|
|
6
|
+
prompt comes from the pr-fixer archetype + the spliced knowledge for
|
|
7
|
+
the company; the tools are git/gh/file-edit primitives behind a strict
|
|
8
|
+
allowlist.
|
|
9
|
+
|
|
10
|
+
The runner is deliberately separate from the scheduler — opt-in via the
|
|
11
|
+
CLI for now. Wiring it into the scheduled prfix task is a follow-up.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from briar.agent.runner import AgentRunner, AgentRunResult
|
|
15
|
+
|
|
16
|
+
__all__ = ["AgentRunner", "AgentRunResult"]
|
briar/agent/_llm.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`LLMProvider` — vendor-neutral facade the agent runner uses instead
|
|
2
|
+
of constructing an Anthropic / OpenAI / Bedrock client directly.
|
|
3
|
+
|
|
4
|
+
Strategy + Registry, same shape as `RepositoryProvider` and
|
|
5
|
+
`TrackerProvider`. Concrete adapters live in `_llms/`.
|
|
6
|
+
|
|
7
|
+
The trick with LLM abstraction is that each vendor's tool-call format
|
|
8
|
+
(Anthropic `tool_use` blocks vs OpenAI `function_call` vs Bedrock's
|
|
9
|
+
shape) differs in both the model's *output* and the format you echo
|
|
10
|
+
*results* back. So this contract has two verbs: `complete` (one turn)
|
|
11
|
+
and `format_tool_result` (how to wire a tool's output into the next
|
|
12
|
+
turn's messages). The runner iterates; the provider stays stateless."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, ClassVar, Dict, List
|
|
20
|
+
|
|
21
|
+
from briar.error_policy import ErrorPolicyRegistry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class LLMToolCall:
|
|
29
|
+
"""One tool-use request from the model."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
arguments: Dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class LLMResponse:
|
|
38
|
+
"""One turn's response, normalised across vendors.
|
|
39
|
+
|
|
40
|
+
`raw_assistant_message` carries the provider-specific echo-back
|
|
41
|
+
payload so the runner can append it to `messages` for the next
|
|
42
|
+
turn without knowing the vendor's format. This is the only field
|
|
43
|
+
that isn't fully normalised — every other field is portable."""
|
|
44
|
+
|
|
45
|
+
text: str
|
|
46
|
+
tool_calls: List[LLMToolCall]
|
|
47
|
+
stop_reason: str
|
|
48
|
+
input_tokens: int
|
|
49
|
+
output_tokens: int
|
|
50
|
+
raw_assistant_message: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LLMProvider(ABC):
|
|
54
|
+
"""Strategy contract. Adapters translate one vendor's API onto
|
|
55
|
+
these two verbs."""
|
|
56
|
+
|
|
57
|
+
kind: ClassVar[str] = ""
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def default_error_policies(cls) -> ErrorPolicyRegistry:
|
|
61
|
+
"""The provider's default error-response policies — consumed
|
|
62
|
+
by the agent runner's RetryingExecutor. Subclasses override to
|
|
63
|
+
encode their SDK's exception taxonomy (rate limits, transient
|
|
64
|
+
errors, 5xx, etc.). Base returns an empty registry — the
|
|
65
|
+
executor will Abort on every exception, preserving the original
|
|
66
|
+
"propagate everything" behaviour for providers that haven't yet
|
|
67
|
+
declared their own policies."""
|
|
68
|
+
return ErrorPolicyRegistry()
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def is_available(self) -> bool:
|
|
72
|
+
"""True iff credentials are present and the provider is usable."""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def complete(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
system: str,
|
|
79
|
+
messages: List[Dict[str, Any]],
|
|
80
|
+
tools: List[Dict[str, Any]],
|
|
81
|
+
max_tokens: int,
|
|
82
|
+
) -> LLMResponse:
|
|
83
|
+
"""One turn. Returns the normalised response. The provider
|
|
84
|
+
handles retries / rate-limit / auth internally."""
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
|
|
88
|
+
"""Provider-specific shape for echoing one tool's output back
|
|
89
|
+
to the model. Anthropic: ``{"type": "tool_result", ...}``;
|
|
90
|
+
OpenAI: ``{"role": "tool", "tool_call_id": ..., "content": ...}``."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""LLM provider registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Tuple, Type
|
|
6
|
+
|
|
7
|
+
from briar._registry import build_registry
|
|
8
|
+
from briar.agent._llm import LLMProvider
|
|
9
|
+
from briar.agent._llms.anthropic_llm import AnthropicLLM
|
|
10
|
+
from briar.agent._llms.bedrock import BedrockLLM
|
|
11
|
+
from briar.agent._llms.gemini import GeminiLLM
|
|
12
|
+
from briar.agent._llms.openai_llm import OpenAILLM
|
|
13
|
+
from briar.errors import CliError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
LLMS: Dict[str, Type[LLMProvider]] = build_registry(
|
|
17
|
+
(AnthropicLLM, OpenAILLM, GeminiLLM, BedrockLLM),
|
|
18
|
+
kind="LLM provider",
|
|
19
|
+
name_attr="kind",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LLMRegistry:
|
|
24
|
+
@classmethod
|
|
25
|
+
def kinds(cls) -> Tuple[str, ...]:
|
|
26
|
+
return tuple(LLMS.keys())
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def make(cls, kind: str, *, model: str = "") -> LLMProvider:
|
|
30
|
+
llm_cls = LLMS.get(kind)
|
|
31
|
+
if llm_cls is None:
|
|
32
|
+
known = ", ".join(sorted(LLMS.keys()))
|
|
33
|
+
raise CliError(f"unknown LLM provider {kind!r}; known: {known}")
|
|
34
|
+
return llm_cls(model=model)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
make_llm = LLMRegistry.make
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["LLMS", "LLMProvider", "LLMRegistry", "make_llm"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Anthropic `LLMProvider`. Captures the call shape that
|
|
2
|
+
`agent/runner.py` previously inlined: OAuth `auth_token` + the
|
|
3
|
+
`oauth-2025-04-20` beta header, the rate-limit retry schedule
|
|
4
|
+
(now expressed declaratively via the error-policy registry), and
|
|
5
|
+
the `tool_use` block normalisation."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from briar.agent._llm import LLMProvider, LLMResponse, LLMToolCall
|
|
14
|
+
from briar.error_policy import (
|
|
15
|
+
Abort,
|
|
16
|
+
ErrorPolicyRegistry,
|
|
17
|
+
ExceptionTypePolicy,
|
|
18
|
+
HttpStatusPolicy,
|
|
19
|
+
RetryAfter,
|
|
20
|
+
RetryingExecutor,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnthropicLLM(LLMProvider):
|
|
28
|
+
kind = "anthropic"
|
|
29
|
+
DEFAULT_MODEL = "claude-sonnet-4-5"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def default_error_policies(cls) -> ErrorPolicyRegistry:
|
|
33
|
+
"""Anthropic's SDK exception taxonomy → retry/abort strategy.
|
|
34
|
+
|
|
35
|
+
Order is intentional — more-specific matches come first. The
|
|
36
|
+
``HttpStatusPolicy(APIStatusError, status=…)`` entries fire
|
|
37
|
+
before the broad ``ExceptionTypePolicy(APIStatusError, …)``
|
|
38
|
+
fallback, so specific status codes get tailored waits.
|
|
39
|
+
|
|
40
|
+
Tunable: edit the ``wait_seconds`` values below or compose an
|
|
41
|
+
overlay via ``registry.with_(extra_policy)`` from a company's
|
|
42
|
+
YAML if/when that lands."""
|
|
43
|
+
import anthropic
|
|
44
|
+
|
|
45
|
+
return ErrorPolicyRegistry(
|
|
46
|
+
policies=(
|
|
47
|
+
# 429 — account-level rate limit. Anthropic resets on
|
|
48
|
+
# the hour; wait 1h then retry rather than the old
|
|
49
|
+
# 30s-doubling schedule, which never recovers when the
|
|
50
|
+
# quota itself is exhausted.
|
|
51
|
+
ExceptionTypePolicy(
|
|
52
|
+
exception_type=anthropic.RateLimitError,
|
|
53
|
+
decision=RetryAfter(wait_seconds=3600, reason="anthropic rate limit"),
|
|
54
|
+
),
|
|
55
|
+
# Transient TCP-level / DNS / TLS errors.
|
|
56
|
+
ExceptionTypePolicy(
|
|
57
|
+
exception_type=anthropic.APIConnectionError,
|
|
58
|
+
decision=RetryAfter(wait_seconds=10, reason="anthropic transient connect"),
|
|
59
|
+
),
|
|
60
|
+
# Targeted 503 (service-unavailable) — short retry.
|
|
61
|
+
HttpStatusPolicy(
|
|
62
|
+
exception_type=anthropic.APIStatusError,
|
|
63
|
+
status=503,
|
|
64
|
+
decision=RetryAfter(wait_seconds=30, reason="anthropic 503 service unavailable"),
|
|
65
|
+
),
|
|
66
|
+
# 529 — Anthropic's "overloaded" signal. Longer wait.
|
|
67
|
+
HttpStatusPolicy(
|
|
68
|
+
exception_type=anthropic.APIStatusError,
|
|
69
|
+
status=529,
|
|
70
|
+
decision=RetryAfter(wait_seconds=120, reason="anthropic 529 overloaded"),
|
|
71
|
+
),
|
|
72
|
+
# 401 / 403 — auth misconfig. No amount of retrying
|
|
73
|
+
# will fix it; abort fast so the operator sees it.
|
|
74
|
+
HttpStatusPolicy(
|
|
75
|
+
exception_type=anthropic.APIStatusError,
|
|
76
|
+
status=401,
|
|
77
|
+
decision=Abort(reason="anthropic 401 — check CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY"),
|
|
78
|
+
),
|
|
79
|
+
HttpStatusPolicy(
|
|
80
|
+
exception_type=anthropic.APIStatusError,
|
|
81
|
+
status=403,
|
|
82
|
+
decision=Abort(reason="anthropic 403 — forbidden (model access / billing)"),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __init__(self, *, model: str = "") -> None:
|
|
88
|
+
self._model = model or self.DEFAULT_MODEL
|
|
89
|
+
self._oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "")
|
|
90
|
+
self._api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
91
|
+
self._client = None
|
|
92
|
+
# Build the executor once per provider instance. The registry
|
|
93
|
+
# is immutable; reusing the same executor across .complete()
|
|
94
|
+
# calls preserves the agent's retry budget across one task.
|
|
95
|
+
self._executor = RetryingExecutor(
|
|
96
|
+
registry=self.default_error_policies(),
|
|
97
|
+
max_attempts=5,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _build_client(self):
|
|
101
|
+
if self._client is not None:
|
|
102
|
+
return self._client
|
|
103
|
+
import anthropic
|
|
104
|
+
|
|
105
|
+
if self._oauth_token:
|
|
106
|
+
# Subscription billing via OAuth; the beta header is required.
|
|
107
|
+
log.info("anthropic-auth: using CLAUDE_CODE_OAUTH_TOKEN")
|
|
108
|
+
self._client = anthropic.Anthropic(
|
|
109
|
+
auth_token=self._oauth_token,
|
|
110
|
+
default_headers={"anthropic-beta": "oauth-2025-04-20"},
|
|
111
|
+
)
|
|
112
|
+
elif self._api_key:
|
|
113
|
+
log.info("anthropic-auth: using ANTHROPIC_API_KEY")
|
|
114
|
+
self._client = anthropic.Anthropic(api_key=self._api_key)
|
|
115
|
+
else:
|
|
116
|
+
raise RuntimeError("Anthropic: CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY required")
|
|
117
|
+
return self._client
|
|
118
|
+
|
|
119
|
+
def is_available(self) -> bool:
|
|
120
|
+
return bool(self._oauth_token or self._api_key)
|
|
121
|
+
|
|
122
|
+
def complete(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
system: str,
|
|
126
|
+
messages: List[Dict[str, Any]],
|
|
127
|
+
tools: List[Dict[str, Any]],
|
|
128
|
+
max_tokens: int,
|
|
129
|
+
) -> LLMResponse:
|
|
130
|
+
"""One turn. The executor handles every retryable failure mode
|
|
131
|
+
declared in ``default_error_policies()`` — this method has zero
|
|
132
|
+
``try / except`` of its own. Adding a new error class is a
|
|
133
|
+
registry entry, not a code change here."""
|
|
134
|
+
client = self._build_client()
|
|
135
|
+
|
|
136
|
+
def _call() -> LLMResponse:
|
|
137
|
+
response = client.messages.create(
|
|
138
|
+
model=self._model,
|
|
139
|
+
max_tokens=max_tokens,
|
|
140
|
+
system=system,
|
|
141
|
+
tools=tools,
|
|
142
|
+
messages=messages,
|
|
143
|
+
)
|
|
144
|
+
return self._normalise(response)
|
|
145
|
+
|
|
146
|
+
return self._executor.run(_call)
|
|
147
|
+
|
|
148
|
+
def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
|
|
149
|
+
result: Dict[str, Any] = {
|
|
150
|
+
"type": "tool_result",
|
|
151
|
+
"tool_use_id": tool_call_id,
|
|
152
|
+
"content": output,
|
|
153
|
+
}
|
|
154
|
+
if is_error:
|
|
155
|
+
result["is_error"] = True
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _normalise(response) -> LLMResponse:
|
|
160
|
+
"""Translate Anthropic's typed response into LLMResponse."""
|
|
161
|
+
tool_calls: List[LLMToolCall] = []
|
|
162
|
+
text_parts: List[str] = []
|
|
163
|
+
for block in response.content:
|
|
164
|
+
block_type = getattr(block, "type", "")
|
|
165
|
+
if block_type == "text":
|
|
166
|
+
text_parts.append(getattr(block, "text", ""))
|
|
167
|
+
elif block_type == "tool_use":
|
|
168
|
+
tool_calls.append(
|
|
169
|
+
LLMToolCall(
|
|
170
|
+
id=getattr(block, "id", ""),
|
|
171
|
+
name=getattr(block, "name", ""),
|
|
172
|
+
arguments=dict(getattr(block, "input", {}) or {}),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return LLMResponse(
|
|
176
|
+
text="".join(text_parts),
|
|
177
|
+
tool_calls=tool_calls,
|
|
178
|
+
stop_reason=str(getattr(response, "stop_reason", "") or ""),
|
|
179
|
+
input_tokens=int((getattr(response, "usage", None) and response.usage.input_tokens) or 0),
|
|
180
|
+
output_tokens=int((getattr(response, "usage", None) and response.usage.output_tokens) or 0),
|
|
181
|
+
raw_assistant_message={
|
|
182
|
+
"role": "assistant",
|
|
183
|
+
"content": [b.model_dump() for b in response.content],
|
|
184
|
+
},
|
|
185
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""AWS Bedrock `LLMProvider`.
|
|
2
|
+
|
|
3
|
+
Uses the unified Bedrock ``Converse`` API which abstracts over the
|
|
4
|
+
provider-specific protocols (Anthropic, Mistral, Meta, Cohere all
|
|
5
|
+
expose the same `converse(modelId, system, messages, toolConfig)`
|
|
6
|
+
surface).
|
|
7
|
+
|
|
8
|
+
Auth: ambient AWS credential chain (boto3 default). Region comes from
|
|
9
|
+
``AWS_REGION`` or the boto3 default chain.
|
|
10
|
+
|
|
11
|
+
Model IDs are vendor-prefixed (e.g.
|
|
12
|
+
``anthropic.claude-sonnet-4-20250514-v1:0``,
|
|
13
|
+
``meta.llama3-70b-instruct-v1:0``). The Converse API normalises the
|
|
14
|
+
response shape across all of them — this adapter doesn't branch on
|
|
15
|
+
the prefix."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Dict, List
|
|
21
|
+
|
|
22
|
+
from briar.agent._llm import LLMProvider, LLMResponse, LLMToolCall
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BedrockLLM(LLMProvider):
|
|
29
|
+
kind = "bedrock"
|
|
30
|
+
DEFAULT_MODEL = "anthropic.claude-sonnet-4-20250514-v1:0"
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, model: str = "") -> None:
|
|
33
|
+
self._model = model or self.DEFAULT_MODEL
|
|
34
|
+
self._client = None
|
|
35
|
+
|
|
36
|
+
def _make_client(self):
|
|
37
|
+
if self._client is not None:
|
|
38
|
+
return self._client
|
|
39
|
+
import boto3
|
|
40
|
+
|
|
41
|
+
self._client = boto3.client("bedrock-runtime")
|
|
42
|
+
return self._client
|
|
43
|
+
|
|
44
|
+
def is_available(self) -> bool:
|
|
45
|
+
try:
|
|
46
|
+
import boto3 # noqa: F401
|
|
47
|
+
|
|
48
|
+
return True
|
|
49
|
+
except ImportError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def complete(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
system: str,
|
|
56
|
+
messages: List[Dict[str, Any]],
|
|
57
|
+
tools: List[Dict[str, Any]],
|
|
58
|
+
max_tokens: int,
|
|
59
|
+
) -> LLMResponse:
|
|
60
|
+
client = self._make_client()
|
|
61
|
+
# Translate Anthropic-shaped `tools` (name/description/input_schema)
|
|
62
|
+
# onto Bedrock's toolConfig.tools[].toolSpec format.
|
|
63
|
+
tool_config = self._to_bedrock_tools(tools)
|
|
64
|
+
kwargs: Dict[str, Any] = {
|
|
65
|
+
"modelId": self._model,
|
|
66
|
+
"system": [{"text": system}] if system else [],
|
|
67
|
+
"messages": self._to_bedrock_messages(messages),
|
|
68
|
+
"inferenceConfig": {"maxTokens": max_tokens},
|
|
69
|
+
}
|
|
70
|
+
if tool_config:
|
|
71
|
+
kwargs["toolConfig"] = tool_config
|
|
72
|
+
|
|
73
|
+
resp = client.converse(**kwargs)
|
|
74
|
+
return self._normalise(resp)
|
|
75
|
+
|
|
76
|
+
def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
|
|
77
|
+
result: Dict[str, Any] = {
|
|
78
|
+
"toolResult": {
|
|
79
|
+
"toolUseId": tool_call_id,
|
|
80
|
+
"content": [{"text": output}],
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if is_error:
|
|
84
|
+
result["toolResult"]["status"] = "error"
|
|
85
|
+
return {"role": "user", "content": [result]}
|
|
86
|
+
|
|
87
|
+
# ---- shape translation ------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _to_bedrock_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
91
|
+
out: List[Dict[str, Any]] = []
|
|
92
|
+
for t in tools:
|
|
93
|
+
out.append(
|
|
94
|
+
{
|
|
95
|
+
"toolSpec": {
|
|
96
|
+
"name": t.get("name", ""),
|
|
97
|
+
"description": t.get("description", ""),
|
|
98
|
+
"inputSchema": {"json": t.get("input_schema", {})},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return {"tools": out} if out else {}
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _to_bedrock_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
106
|
+
"""Bedrock content blocks differ from Anthropic's: Anthropic uses
|
|
107
|
+
``{type: tool_result, tool_use_id, content}``; Bedrock uses
|
|
108
|
+
``{toolResult: {toolUseId, content}}``. The format_tool_result
|
|
109
|
+
method emits the Bedrock shape already; this just passes things
|
|
110
|
+
through, only translating the simple ``{role, content: <str>}``
|
|
111
|
+
case where the user message is plain text."""
|
|
112
|
+
out: List[Dict[str, Any]] = []
|
|
113
|
+
for m in messages:
|
|
114
|
+
content = m.get("content")
|
|
115
|
+
if isinstance(content, str):
|
|
116
|
+
out.append({"role": m.get("role", "user"), "content": [{"text": content}]})
|
|
117
|
+
else:
|
|
118
|
+
out.append(m)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _normalise(resp: Dict[str, Any]) -> LLMResponse:
|
|
123
|
+
output = (resp.get("output") or {}).get("message") or {}
|
|
124
|
+
blocks = output.get("content") or []
|
|
125
|
+
text_parts: List[str] = []
|
|
126
|
+
tool_calls: List[LLMToolCall] = []
|
|
127
|
+
for block in blocks:
|
|
128
|
+
if "text" in block:
|
|
129
|
+
text_parts.append(str(block["text"]))
|
|
130
|
+
elif "toolUse" in block:
|
|
131
|
+
tu = block["toolUse"]
|
|
132
|
+
tool_calls.append(
|
|
133
|
+
LLMToolCall(
|
|
134
|
+
id=str(tu.get("toolUseId") or ""),
|
|
135
|
+
name=str(tu.get("name") or ""),
|
|
136
|
+
arguments=dict(tu.get("input") or {}),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
usage = resp.get("usage") or {}
|
|
140
|
+
# Bedrock reports `end_turn` / `tool_use` / `max_tokens` / `stop_sequence`
|
|
141
|
+
# in `stopReason` — same vocabulary as Anthropic, snake-cased.
|
|
142
|
+
stop = str(resp.get("stopReason") or "")
|
|
143
|
+
if stop == "endTurn":
|
|
144
|
+
stop = "end_turn"
|
|
145
|
+
elif stop == "toolUse":
|
|
146
|
+
stop = "tool_use"
|
|
147
|
+
return LLMResponse(
|
|
148
|
+
text="".join(text_parts),
|
|
149
|
+
tool_calls=tool_calls,
|
|
150
|
+
stop_reason=stop,
|
|
151
|
+
input_tokens=int(usage.get("inputTokens") or 0),
|
|
152
|
+
output_tokens=int(usage.get("outputTokens") or 0),
|
|
153
|
+
raw_assistant_message={"role": "assistant", "content": blocks},
|
|
154
|
+
)
|