agentkernel-cli 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.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agentkernel/paths.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Location policy for running the agent outside its own project folder.
|
|
2
|
+
|
|
3
|
+
Two roots:
|
|
4
|
+
|
|
5
|
+
* **Agent home** — the user-global "brain & library" (memory notebook, knowledge
|
|
6
|
+
graph, skills, profiles, improvements, scheduled jobs). Shared across every
|
|
7
|
+
project. ``$AGENTKERNEL_HOME`` overrides the default ``~/.agentkernel``.
|
|
8
|
+
* **Project root** — the directory the agent operates on, found by walking up
|
|
9
|
+
from the target directory for a marker (``agentkernel.toml`` / ``.agentkernel``
|
|
10
|
+
/ ``.git``). Project-local state (session traces, kanban board, checkpoints)
|
|
11
|
+
lives under ``<project>/.agentkernel``.
|
|
12
|
+
|
|
13
|
+
``anchor_path`` resolves a configured path against the right root, leaving
|
|
14
|
+
absolute paths untouched, so "global brain, project sessions" works no matter
|
|
15
|
+
where you launch ``agentkernel``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
HOME_ENV = "AGENTKERNEL_HOME"
|
|
24
|
+
DEFAULT_HOME_DIRNAME = ".agentkernel"
|
|
25
|
+
_CONFIG_MARKERS = ("agentkernel.toml", ".agentkernel")
|
|
26
|
+
_FALLBACK_MARKERS = (".git",)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def agent_home(env: dict[str, str] | None = None) -> Path:
|
|
30
|
+
"""The user-global agent home (``$AGENTKERNEL_HOME`` or ``~/.agentkernel``)."""
|
|
31
|
+
source = os.environ if env is None else env
|
|
32
|
+
override = source.get(HOME_ENV)
|
|
33
|
+
if override:
|
|
34
|
+
return Path(override).expanduser()
|
|
35
|
+
return Path.home() / DEFAULT_HOME_DIRNAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_project_root(start: str | os.PathLike[str] = ".") -> Path:
|
|
39
|
+
"""Walk up from ``start`` to the nearest project root.
|
|
40
|
+
|
|
41
|
+
Prefers a directory containing ``agentkernel.toml`` or ``.agentkernel``; falls
|
|
42
|
+
back to a ``.git`` root; otherwise returns ``start`` itself.
|
|
43
|
+
"""
|
|
44
|
+
here = Path(start).expanduser().resolve()
|
|
45
|
+
if here.is_file():
|
|
46
|
+
here = here.parent
|
|
47
|
+
candidates = [here, *here.parents]
|
|
48
|
+
for directory in candidates:
|
|
49
|
+
if any((directory / marker).exists() for marker in _CONFIG_MARKERS):
|
|
50
|
+
return directory
|
|
51
|
+
for directory in candidates:
|
|
52
|
+
if any((directory / marker).exists() for marker in _FALLBACK_MARKERS):
|
|
53
|
+
return directory
|
|
54
|
+
return here
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_project_config(project_root: Path) -> Path | None:
|
|
58
|
+
"""The project's ``agentkernel.toml`` if present."""
|
|
59
|
+
candidate = project_root / "agentkernel.toml"
|
|
60
|
+
return candidate if candidate.is_file() else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def global_config_path(home: Path | None = None) -> Path:
|
|
64
|
+
"""The user-global config file (``<home>/config.toml``)."""
|
|
65
|
+
return (home or agent_home()) / "config.toml"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def anchor_path(value: str | os.PathLike[str], *, base: Path) -> str:
|
|
69
|
+
"""Resolve ``value`` against ``base``; absolute and ``~`` paths are honored."""
|
|
70
|
+
path = Path(value).expanduser()
|
|
71
|
+
if path.is_absolute():
|
|
72
|
+
return str(path)
|
|
73
|
+
return str((base / path).resolve())
|
agentkernel/plugins.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Plugin tool discovery (design §18.7).
|
|
2
|
+
|
|
3
|
+
Loads user-authored tools from a ``plugins/`` directory and registers them
|
|
4
|
+
exactly like builtins — the same seam MCP uses (§13). A plugin is a ``.py`` file
|
|
5
|
+
that exposes either a ``tools()`` callable returning ``list[ToolSpec]`` (optionally
|
|
6
|
+
taking ``working_dir``) or a module-level ``TOOLS`` list.
|
|
7
|
+
|
|
8
|
+
SECURITY: importing a plugin executes its module code. Discovery is opt-in
|
|
9
|
+
(``enable_plugins``), and only files you place in ``plugins_dir`` are loaded.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import inspect
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from agentkernel.tools.base import ToolSpec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _import_file(path: Path):
|
|
22
|
+
spec = importlib.util.spec_from_file_location(f"agentkernel_plugin_{path.stem}", path)
|
|
23
|
+
if spec is None or spec.loader is None:
|
|
24
|
+
return None
|
|
25
|
+
module = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
return module
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _call_tools_fn(fn, working_dir: str) -> list[ToolSpec]:
|
|
31
|
+
"""Call a plugin's ``tools`` entrypoint, passing working_dir if it accepts it."""
|
|
32
|
+
try:
|
|
33
|
+
params = inspect.signature(fn).parameters
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
params = {}
|
|
36
|
+
result = fn(working_dir) if params else fn()
|
|
37
|
+
return [s for s in (result or []) if isinstance(s, ToolSpec)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract(module, working_dir: str) -> list[ToolSpec]:
|
|
41
|
+
specs: list[ToolSpec] = []
|
|
42
|
+
fn = getattr(module, "tools", None)
|
|
43
|
+
if callable(fn):
|
|
44
|
+
specs.extend(_call_tools_fn(fn, working_dir))
|
|
45
|
+
table = getattr(module, "TOOLS", None)
|
|
46
|
+
if isinstance(table, (list, tuple)):
|
|
47
|
+
specs.extend(s for s in table if isinstance(s, ToolSpec))
|
|
48
|
+
return specs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_plugin_tools(
|
|
52
|
+
plugins_dir: str | Path,
|
|
53
|
+
*,
|
|
54
|
+
working_dir: str = ".",
|
|
55
|
+
on_error=None,
|
|
56
|
+
) -> list[ToolSpec]:
|
|
57
|
+
"""Discover and return tools from every ``*.py`` in ``plugins_dir``.
|
|
58
|
+
|
|
59
|
+
A module that fails to import is skipped; ``on_error(path, exc)`` is called if
|
|
60
|
+
provided so the caller can warn. Files starting with ``_`` are ignored.
|
|
61
|
+
"""
|
|
62
|
+
directory = Path(plugins_dir)
|
|
63
|
+
specs: list[ToolSpec] = []
|
|
64
|
+
if not directory.is_dir():
|
|
65
|
+
return specs
|
|
66
|
+
for path in sorted(directory.glob("*.py")):
|
|
67
|
+
if path.name.startswith("_"):
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
module = _import_file(path)
|
|
71
|
+
if module is not None:
|
|
72
|
+
specs.extend(_extract(module, working_dir))
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - a bad plugin must not crash startup
|
|
74
|
+
if on_error is not None:
|
|
75
|
+
on_error(path, exc)
|
|
76
|
+
return specs
|
agentkernel/profiles.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Profile seam (Phase 5, design §13).
|
|
2
|
+
|
|
3
|
+
A ``Profile`` parameterizes a run: system prompt, tool filter, optional model
|
|
4
|
+
override, and an optional rubric for evaluation. The kernel honors
|
|
5
|
+
``system_prompt`` and ``tool_filter``; ``model_override`` and ``rubric`` are
|
|
6
|
+
extension points for later phases / the CLI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Profile:
|
|
20
|
+
"""A parameterization of one run (design §13)."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
system_prompt: str | None = None
|
|
24
|
+
tool_filter: list[str] | None = None
|
|
25
|
+
model_override: str | None = None
|
|
26
|
+
rubric: str | None = None
|
|
27
|
+
reasoning: str | None = None # "low" | "medium" | "high" (§18.5); ignored where unsupported
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize(raw: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
+
"""Accept snake_case keys plus the older-style aliases in TOML files."""
|
|
32
|
+
out: dict[str, Any] = {}
|
|
33
|
+
for key, value in raw.items():
|
|
34
|
+
out[key] = value
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_profile(name: str, *, search_dirs: Sequence[Path] | None = None) -> Profile | None:
|
|
39
|
+
"""Load a profile by name from the first matching TOML file.
|
|
40
|
+
|
|
41
|
+
Searches, in order:
|
|
42
|
+
- ``<search_dir>/<name>.toml`` for each directory in ``search_dirs``
|
|
43
|
+
- ``profiles/<name>.toml``
|
|
44
|
+
- ``.agentkernel/profiles/<name>.toml``
|
|
45
|
+
|
|
46
|
+
Returns ``None`` if no file is found.
|
|
47
|
+
"""
|
|
48
|
+
if search_dirs is None:
|
|
49
|
+
search_dirs = []
|
|
50
|
+
search_dirs = [
|
|
51
|
+
*search_dirs,
|
|
52
|
+
Path("profiles"),
|
|
53
|
+
Path(".agentkernel/profiles"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for directory in search_dirs:
|
|
57
|
+
path = Path(directory) / f"{name}.toml"
|
|
58
|
+
if path.is_file():
|
|
59
|
+
with path.open("rb") as fh:
|
|
60
|
+
data = tomllib.load(fh)
|
|
61
|
+
values = _normalize(data)
|
|
62
|
+
return Profile(
|
|
63
|
+
name=name,
|
|
64
|
+
system_prompt=values.get("system_prompt"),
|
|
65
|
+
tool_filter=values.get("tool_filter"),
|
|
66
|
+
model_override=values.get("model_override"),
|
|
67
|
+
rubric=values.get("rubric"),
|
|
68
|
+
reasoning=values.get("reasoning"),
|
|
69
|
+
)
|
|
70
|
+
return None
|
agentkernel/progress.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Per-turn progress display for the REPL.
|
|
2
|
+
|
|
3
|
+
Wraps a ``JsonlTelemetry`` so every recorded turn is also printed as a concise,
|
|
4
|
+
one-line status line. The progress wrapper tracks cumulative usage and cost for
|
|
5
|
+
the current REPL session without changing the underlying telemetry interface or
|
|
6
|
+
the agent loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from agentkernel.budget import BudgetGuard
|
|
15
|
+
from agentkernel.context import CompactionEvent
|
|
16
|
+
from agentkernel.telemetry import JsonlTelemetry, ToolOutcome, estimate_cost
|
|
17
|
+
from agentkernel.types import CompletionResponse, Usage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ProgressTelemetry:
|
|
22
|
+
"""Prints a status line for each turn and keeps cumulative totals."""
|
|
23
|
+
|
|
24
|
+
telemetry: JsonlTelemetry
|
|
25
|
+
output_fn: Callable[[str], None]
|
|
26
|
+
_cumulative: BudgetGuard = field(init=False)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
self._cumulative = BudgetGuard(
|
|
30
|
+
model=self.telemetry.model,
|
|
31
|
+
prices=self.telemetry.prices.copy(),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def path(self) -> str:
|
|
36
|
+
return str(self.telemetry.path)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def cumulative_cost(self) -> float | None:
|
|
40
|
+
return self._cumulative.total_cost
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def cumulative_usage(self) -> Usage:
|
|
44
|
+
return self._cumulative.total_usage
|
|
45
|
+
|
|
46
|
+
def record_turn(
|
|
47
|
+
self,
|
|
48
|
+
iteration: int,
|
|
49
|
+
response: CompletionResponse,
|
|
50
|
+
*,
|
|
51
|
+
tool_outcomes: Sequence[ToolOutcome] = (),
|
|
52
|
+
compaction: CompactionEvent | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._cumulative.add(response.usage)
|
|
55
|
+
self.output_fn(_format_line(iteration, response, tool_outcomes, self._cumulative))
|
|
56
|
+
self.telemetry.record_turn(
|
|
57
|
+
iteration,
|
|
58
|
+
response,
|
|
59
|
+
tool_outcomes=tool_outcomes,
|
|
60
|
+
compaction=compaction,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self.telemetry.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_line(
|
|
68
|
+
iteration: int,
|
|
69
|
+
response: CompletionResponse,
|
|
70
|
+
tool_outcomes: Sequence[ToolOutcome],
|
|
71
|
+
cumulative: BudgetGuard,
|
|
72
|
+
) -> str:
|
|
73
|
+
usage = response.usage
|
|
74
|
+
parts = [f"[{iteration}]"]
|
|
75
|
+
if response.message.tool_calls:
|
|
76
|
+
names = [o.name for o in tool_outcomes]
|
|
77
|
+
parts.append(f"tool_use: {', '.join(names) if names else '...'}")
|
|
78
|
+
else:
|
|
79
|
+
parts.append(response.stop_reason)
|
|
80
|
+
parts.append(f"in={usage.input_tokens} out={usage.output_tokens}")
|
|
81
|
+
if usage.cache_read_tokens or usage.cache_write_tokens:
|
|
82
|
+
parts.append(f"cache={usage.cache_read_tokens}/{usage.cache_write_tokens}")
|
|
83
|
+
turn_cost = estimate_cost(cumulative.model, usage, cumulative.prices)
|
|
84
|
+
if turn_cost is not None:
|
|
85
|
+
parts.append(f"this=${turn_cost:.6f}")
|
|
86
|
+
total_cost = cumulative.total_cost
|
|
87
|
+
if total_cost is not None:
|
|
88
|
+
parts.append(f"total=${total_cost:.6f}")
|
|
89
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Provider abstraction and adapters (design §5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from agentkernel.providers._http import ProviderError
|
|
8
|
+
from agentkernel.providers.anthropic import AnthropicProvider
|
|
9
|
+
from agentkernel.providers.base import Provider
|
|
10
|
+
from agentkernel.providers.local import LocalProvider
|
|
11
|
+
from agentkernel.providers.openai import OpenAIProvider
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from agentkernel.config import Config
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Provider",
|
|
18
|
+
"ProviderError",
|
|
19
|
+
"AnthropicProvider",
|
|
20
|
+
"OpenAIProvider",
|
|
21
|
+
"LocalProvider",
|
|
22
|
+
"make_provider",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_provider(config: Config) -> Provider:
|
|
27
|
+
"""Construct the adapter named by ``config.provider``. Keys come from env."""
|
|
28
|
+
if config.provider == "anthropic":
|
|
29
|
+
return AnthropicProvider(config.model)
|
|
30
|
+
if config.provider == "openai":
|
|
31
|
+
return OpenAIProvider(config.model)
|
|
32
|
+
if config.provider == "local":
|
|
33
|
+
kwargs = {} if config.base_url is None else {"base_url": config.base_url}
|
|
34
|
+
return LocalProvider(config.model, **kwargs)
|
|
35
|
+
raise ProviderError(f"unknown provider: {config.provider!r}")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Shared HTTP transport for provider adapters.
|
|
2
|
+
|
|
3
|
+
A provider that is unreachable after retries raises ``ProviderError`` — one of
|
|
4
|
+
the few kernel faults that is allowed to propagate out of the loop (design §8.3).
|
|
5
|
+
Tests never touch this module: they exercise the pure translation functions
|
|
6
|
+
directly, so the suite stays offline (design §15).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import email.utils
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
20
|
+
|
|
21
|
+
# Cap how long a server-supplied Retry-After can stall us, so a hostile or
|
|
22
|
+
# misconfigured header can't park the loop for minutes.
|
|
23
|
+
_MAX_RETRY_AFTER = 30.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProviderError(RuntimeError):
|
|
27
|
+
"""A non-recoverable provider/transport fault (config or network)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RateLimitError(ProviderError):
|
|
31
|
+
"""The request was rate-limited (HTTP 429) after retries — a pool may rotate."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
35
|
+
"""Parse a ``Retry-After`` header (delta-seconds or HTTP-date) to seconds.
|
|
36
|
+
|
|
37
|
+
Returns ``None`` when the header is absent or unparseable, and never a
|
|
38
|
+
negative delay (a past date clamps to 0).
|
|
39
|
+
"""
|
|
40
|
+
if not value:
|
|
41
|
+
return None
|
|
42
|
+
value = value.strip()
|
|
43
|
+
if value.isdigit():
|
|
44
|
+
return float(value)
|
|
45
|
+
try:
|
|
46
|
+
parsed = email.utils.parsedate_to_datetime(value)
|
|
47
|
+
except (ValueError, TypeError):
|
|
48
|
+
return None
|
|
49
|
+
if parsed is None:
|
|
50
|
+
return None
|
|
51
|
+
delay = parsed.timestamp() - time.time()
|
|
52
|
+
return max(0.0, delay)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def post_json(
|
|
56
|
+
url: str,
|
|
57
|
+
*,
|
|
58
|
+
headers: dict[str, str],
|
|
59
|
+
payload: dict[str, Any],
|
|
60
|
+
timeout: float = 120.0,
|
|
61
|
+
retries: int = 2,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""POST ``payload`` as JSON and return the parsed JSON response.
|
|
64
|
+
|
|
65
|
+
Retries transient transport errors and retryable status codes; raises
|
|
66
|
+
``ProviderError`` once retries are exhausted or on a non-retryable 4xx.
|
|
67
|
+
"""
|
|
68
|
+
last_detail = ""
|
|
69
|
+
last_status: int | None = None
|
|
70
|
+
for attempt in range(retries + 1):
|
|
71
|
+
retry_after: float | None = None
|
|
72
|
+
try:
|
|
73
|
+
resp = httpx.post(url, headers=headers, json=payload, timeout=timeout)
|
|
74
|
+
except httpx.TransportError as exc:
|
|
75
|
+
last_detail = f"transport error: {exc}"
|
|
76
|
+
last_status = None
|
|
77
|
+
else:
|
|
78
|
+
if resp.status_code < 400:
|
|
79
|
+
return resp.json()
|
|
80
|
+
# Body may contain provider error detail but never our secrets.
|
|
81
|
+
last_status = resp.status_code
|
|
82
|
+
last_detail = f"HTTP {resp.status_code}: {resp.text[:500]}"
|
|
83
|
+
if resp.status_code not in _RETRYABLE_STATUS:
|
|
84
|
+
raise ProviderError(f"{url} -> {last_detail}")
|
|
85
|
+
# Honor Retry-After (429/503) when the server tells us how long to
|
|
86
|
+
# wait, bounded so a bad header can't stall the loop indefinitely.
|
|
87
|
+
retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
|
|
88
|
+
if attempt < retries:
|
|
89
|
+
backoff = 0.5 * (attempt + 1)
|
|
90
|
+
delay = min(retry_after, _MAX_RETRY_AFTER) if retry_after is not None else backoff
|
|
91
|
+
time.sleep(delay)
|
|
92
|
+
# A 429 that survived retries is recoverable by rotating to another key.
|
|
93
|
+
err = RateLimitError if last_status == 429 else ProviderError
|
|
94
|
+
raise err(f"{url} unreachable after {retries + 1} attempts; {last_detail}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stream_sse(
|
|
98
|
+
url: str,
|
|
99
|
+
*,
|
|
100
|
+
headers: dict[str, str],
|
|
101
|
+
payload: dict[str, Any],
|
|
102
|
+
timeout: float = 600.0,
|
|
103
|
+
) -> Iterator[dict[str, Any]]:
|
|
104
|
+
"""Yield parsed ``data:`` JSON objects from a streaming (SSE) POST.
|
|
105
|
+
|
|
106
|
+
Raises ``ProviderError`` on a non-2xx status (the error body is read first).
|
|
107
|
+
Both Anthropic and OpenAI carry a ``type``/``choices`` field in each ``data:``
|
|
108
|
+
payload, so callers parse the event from the JSON itself; ``event:`` lines and
|
|
109
|
+
keep-alives are ignored. ``[DONE]`` terminates the stream.
|
|
110
|
+
"""
|
|
111
|
+
with httpx.stream("POST", url, headers=headers, json=payload, timeout=timeout) as resp:
|
|
112
|
+
if resp.status_code >= 400:
|
|
113
|
+
body = resp.read().decode("utf-8", "replace")[:500]
|
|
114
|
+
raise ProviderError(f"{url} -> HTTP {resp.status_code}: {body}")
|
|
115
|
+
for raw in resp.iter_lines():
|
|
116
|
+
line = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
|
|
117
|
+
line = line.strip()
|
|
118
|
+
if not line or not line.startswith("data:"):
|
|
119
|
+
continue
|
|
120
|
+
data = line[len("data:"):].strip()
|
|
121
|
+
if data == "[DONE]":
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
yield json.loads(data)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def post_json_pooled(
|
|
130
|
+
url: str,
|
|
131
|
+
*,
|
|
132
|
+
header_for_key,
|
|
133
|
+
payload: dict[str, Any],
|
|
134
|
+
pool,
|
|
135
|
+
timeout: float = 120.0,
|
|
136
|
+
retries: int = 2,
|
|
137
|
+
_post=None,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""POST with credential rotation: on a rate limit, mark the key exhausted and
|
|
140
|
+
retry with the next one in ``pool`` until a key works or all are spent."""
|
|
141
|
+
post = _post if _post is not None else post_json
|
|
142
|
+
last_exc: ProviderError | None = None
|
|
143
|
+
for _ in range(max(1, len(pool))):
|
|
144
|
+
key = pool.current()
|
|
145
|
+
try:
|
|
146
|
+
return post(
|
|
147
|
+
url, headers=header_for_key(key), payload=payload,
|
|
148
|
+
timeout=timeout, retries=retries,
|
|
149
|
+
)
|
|
150
|
+
except RateLimitError as exc:
|
|
151
|
+
last_exc = exc
|
|
152
|
+
pool.mark_exhausted()
|
|
153
|
+
if not pool.rotate():
|
|
154
|
+
break
|
|
155
|
+
if last_exc is not None:
|
|
156
|
+
raise last_exc
|
|
157
|
+
raise ProviderError(f"{url}: no credentials available")
|