workflowval 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.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Counts of model and tool calls per workflow, and a step limit suggested from past runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Collection, Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from workflowval.models.base import ToolCall
|
|
17
|
+
from workflowval.schemas import CallCounts, RunStats, StatsTotal
|
|
18
|
+
|
|
19
|
+
MIN_RUNS_FOR_SUGGESTION = 5
|
|
20
|
+
RECENT_RUNS = 20
|
|
21
|
+
MIN_SUGGESTED_LIMIT = 10
|
|
22
|
+
UNKNOWN_TOOL = "<unknown>"
|
|
23
|
+
MAX_STEPS_REASON = "max_steps"
|
|
24
|
+
# Runs that ended for other reasons stopped early, so they'd make the limit look too generous.
|
|
25
|
+
USABLE_STOP_REASONS = frozenset({"completed", MAX_STEPS_REASON, "max_iterations", "no_progress"})
|
|
26
|
+
|
|
27
|
+
_WORKFLOW_ID = re.compile(r"[a-z0-9_]+")
|
|
28
|
+
_TEST_FILE = re.compile(r"(?:^|/)test_([a-z0-9_]+)\.py(?:::|$)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class _Tally:
|
|
33
|
+
model_calls: int = 0
|
|
34
|
+
cost_usd: float = 0.0
|
|
35
|
+
tools: Counter[str] = field(default_factory=Counter)
|
|
36
|
+
|
|
37
|
+
def counts(self) -> CallCounts:
|
|
38
|
+
return CallCounts(
|
|
39
|
+
model_calls=self.model_calls,
|
|
40
|
+
tool_calls=dict(sorted(self.tools.items())),
|
|
41
|
+
cost_usd=self.cost_usd,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def workflow_of(arguments: Mapping[str, Any]) -> str | None:
|
|
46
|
+
"""Return the workflow a tool call's arguments name, if they name one validly."""
|
|
47
|
+
workflow = arguments.get("workflow")
|
|
48
|
+
candidates = [
|
|
49
|
+
arguments.get("workflow_id"),
|
|
50
|
+
workflow.get("id") if isinstance(workflow, Mapping) else None,
|
|
51
|
+
]
|
|
52
|
+
for candidate in candidates:
|
|
53
|
+
if isinstance(candidate, str) and _WORKFLOW_ID.fullmatch(candidate):
|
|
54
|
+
return candidate
|
|
55
|
+
test_id = arguments.get("test_id")
|
|
56
|
+
if isinstance(test_id, str) and (match := _TEST_FILE.search(test_id)):
|
|
57
|
+
return match.group(1)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StatsCollector:
|
|
62
|
+
"""Collects one run's counts and costs, per workflow and per role."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, limit: int, spec_hash: str = "") -> None:
|
|
65
|
+
self.limit = limit
|
|
66
|
+
self.spec_hash = spec_hash
|
|
67
|
+
self._owners: dict[str | None, _Tally] = {}
|
|
68
|
+
self._roles: dict[str, _Tally] = {}
|
|
69
|
+
|
|
70
|
+
def _tallies(self, owner: str | None, role: str | None) -> list[_Tally]:
|
|
71
|
+
tallies = [self._owners.setdefault(owner, _Tally())]
|
|
72
|
+
if role is not None:
|
|
73
|
+
tallies.append(self._roles.setdefault(role, _Tally()))
|
|
74
|
+
return tallies
|
|
75
|
+
|
|
76
|
+
def record_failed_call(self, scope: str | None, role: str | None = None) -> None:
|
|
77
|
+
"""Count a model call that failed, which still used a step."""
|
|
78
|
+
for tally in self._tallies(scope, role):
|
|
79
|
+
tally.model_calls += 1
|
|
80
|
+
|
|
81
|
+
def record_reply(
|
|
82
|
+
self,
|
|
83
|
+
scope: str | None,
|
|
84
|
+
calls: Iterable[ToolCall],
|
|
85
|
+
known_tools: Collection[str],
|
|
86
|
+
role: str | None = None,
|
|
87
|
+
cost_usd: float | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Count one model call, its cost and its tool calls.
|
|
90
|
+
|
|
91
|
+
A model can invent tool names and they aren't safe to store, so those count as unknown.
|
|
92
|
+
"""
|
|
93
|
+
owners: set[str | None] = set()
|
|
94
|
+
for call in calls:
|
|
95
|
+
owner = scope or workflow_of(call.arguments)
|
|
96
|
+
owners.add(owner)
|
|
97
|
+
name = call.name if call.name in known_tools else UNKNOWN_TOOL
|
|
98
|
+
for tally in self._tallies(owner, role):
|
|
99
|
+
tally.tools[name] += 1
|
|
100
|
+
model_owner = owners.pop() if scope is None and len(owners) == 1 else scope
|
|
101
|
+
for tally in self._tallies(model_owner, role):
|
|
102
|
+
tally.model_calls += 1
|
|
103
|
+
tally.cost_usd += cost_usd or 0.0
|
|
104
|
+
|
|
105
|
+
def snapshot(self, stop_reason: str | None = None) -> RunStats:
|
|
106
|
+
"""Return the counts so far."""
|
|
107
|
+
tallies = list(self._owners.values())
|
|
108
|
+
return RunStats(
|
|
109
|
+
run=self._owners.get(None, _Tally()).counts(),
|
|
110
|
+
workflows={
|
|
111
|
+
owner: tally.counts()
|
|
112
|
+
for owner, tally in sorted(
|
|
113
|
+
(item for item in self._owners.items() if item[0] is not None),
|
|
114
|
+
key=lambda item: str(item[0]),
|
|
115
|
+
)
|
|
116
|
+
if owner is not None
|
|
117
|
+
},
|
|
118
|
+
roles={role: self._roles[role].counts() for role in sorted(self._roles)},
|
|
119
|
+
total=StatsTotal(
|
|
120
|
+
model_calls=sum(tally.model_calls for tally in tallies),
|
|
121
|
+
tool_calls=sum(sum(tally.tools.values()) for tally in tallies),
|
|
122
|
+
cost_usd=sum(tally.cost_usd for tally in tallies),
|
|
123
|
+
limit=self.limit,
|
|
124
|
+
),
|
|
125
|
+
spec_hash=self.spec_hash,
|
|
126
|
+
stop_reason=stop_reason,
|
|
127
|
+
finished_at=datetime.now(UTC).isoformat() if stop_reason else None,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def append_history(stats: RunStats, path: Path) -> None:
|
|
132
|
+
"""Add a finished run to the history."""
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
135
|
+
handle.write(stats.model_dump_json(exclude_none=True) + "\n")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_history(path: Path) -> list[RunStats]:
|
|
139
|
+
"""Read past runs. Bad lines are skipped, since they must never stop a run from finishing."""
|
|
140
|
+
if not path.exists():
|
|
141
|
+
return []
|
|
142
|
+
runs = []
|
|
143
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
144
|
+
try:
|
|
145
|
+
runs.append(RunStats.model_validate_json(line))
|
|
146
|
+
except ValidationError:
|
|
147
|
+
continue
|
|
148
|
+
return runs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _one_and_a_half_times(value: int) -> int:
|
|
152
|
+
"""Return 1.5 times a value, rounded up to the next 10."""
|
|
153
|
+
return math.ceil(value * 15 / 100) * 10
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def suggest_max_steps(history: Iterable[RunStats], limit: int) -> str | None:
|
|
157
|
+
"""Suggest a `--max-steps` value from past runs. This only ever advises; it changes nothing."""
|
|
158
|
+
usable = [run for run in history if run.stop_reason in USABLE_STOP_REASONS][-RECENT_RUNS:]
|
|
159
|
+
if len(usable) < MIN_RUNS_FOR_SUGGESTION:
|
|
160
|
+
return None
|
|
161
|
+
# A run that hit a lower, older limit says nothing against the current limit.
|
|
162
|
+
hits = [
|
|
163
|
+
run for run in usable if run.stop_reason == MAX_STEPS_REASON and run.total.limit >= limit
|
|
164
|
+
]
|
|
165
|
+
if hits:
|
|
166
|
+
return (
|
|
167
|
+
f"{len(hits)} of your last {len(usable)} runs hit the step limit of {limit}; "
|
|
168
|
+
f"consider --max-steps {_one_and_a_half_times(limit)}"
|
|
169
|
+
)
|
|
170
|
+
largest = max(run.total.model_calls for run in usable)
|
|
171
|
+
target = max(_one_and_a_half_times(largest), MIN_SUGGESTED_LIMIT)
|
|
172
|
+
if target == limit:
|
|
173
|
+
return None
|
|
174
|
+
steps = "step" if largest == 1 else "steps"
|
|
175
|
+
return (
|
|
176
|
+
f"the largest of your last {len(usable)} runs used {largest} {steps}; "
|
|
177
|
+
f"consider --max-steps {target}"
|
|
178
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""`.workflowval/trace.jsonl`: every model and tool call (NFR-6), with secrets removed (NFR-7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from workflowval.redact import redact
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Tracer:
|
|
15
|
+
"""Appends one JSON object per event, flushed at once so a killed run keeps its trace."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: Path, *, echo: bool = False) -> None:
|
|
18
|
+
self.path = path
|
|
19
|
+
self.echo = echo
|
|
20
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def event(self, kind: str, **fields: Any) -> None:
|
|
23
|
+
"""Record one event."""
|
|
24
|
+
record = {"ts": datetime.now(UTC).isoformat(), "kind": kind, **redact(fields)}
|
|
25
|
+
line = json.dumps(record, default=str)
|
|
26
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
27
|
+
handle.write(line + "\n")
|
|
28
|
+
if self.echo:
|
|
29
|
+
print(line, file=sys.stderr)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Provider adapters behind one interface. LiteLLM is imported only when a call is made."""
|
|
2
|
+
|
|
3
|
+
from workflowval.models.base import Message, Model, ModelError, Reply, ToolCall, ToolSpec, Usage
|
|
4
|
+
from workflowval.models.fake import FakeModel, FakeScriptExhausted, call
|
|
5
|
+
from workflowval.models.litellm_adapter import LiteLLMModel
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FakeModel",
|
|
9
|
+
"FakeScriptExhausted",
|
|
10
|
+
"LiteLLMModel",
|
|
11
|
+
"Message",
|
|
12
|
+
"Model",
|
|
13
|
+
"ModelError",
|
|
14
|
+
"Reply",
|
|
15
|
+
"ToolCall",
|
|
16
|
+
"ToolSpec",
|
|
17
|
+
"Usage",
|
|
18
|
+
"call",
|
|
19
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The one interface the rest of workflowval uses to talk to a model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelError(Exception):
|
|
11
|
+
"""A provider call failed. The message is already redacted."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ToolCall:
|
|
16
|
+
"""A tool call from the model. `parse_error` is set when its arguments weren't valid JSON."""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
name: str
|
|
20
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
parse_error: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Message:
|
|
26
|
+
"""One conversation message."""
|
|
27
|
+
|
|
28
|
+
role: Literal["system", "user", "assistant", "tool"]
|
|
29
|
+
content: str = ""
|
|
30
|
+
tool_calls: tuple[ToolCall, ...] = ()
|
|
31
|
+
tool_call_id: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ToolSpec:
|
|
36
|
+
"""What the model sees of a tool."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
parameters: dict[str, Any]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Usage:
|
|
45
|
+
"""Tokens and cost of one call. `cost_usd` is None when the model has no known price."""
|
|
46
|
+
|
|
47
|
+
input_tokens: int = 0
|
|
48
|
+
output_tokens: int = 0
|
|
49
|
+
cost_usd: float | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Reply:
|
|
54
|
+
"""A model's answer."""
|
|
55
|
+
|
|
56
|
+
text: str = ""
|
|
57
|
+
tool_calls: tuple[ToolCall, ...] = ()
|
|
58
|
+
usage: Usage = field(default_factory=Usage)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Model(Protocol):
|
|
62
|
+
"""Anything that can answer a conversation."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
|
|
66
|
+
def complete(self, messages: Sequence[Message], tools: Sequence[ToolSpec]) -> Reply:
|
|
67
|
+
"""Return the model's reply. Raises ModelError if the provider call fails."""
|
|
68
|
+
...
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""A scripted model, which makes the loop fully deterministic in tests and CI (NFR-8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from workflowval.models.base import Message, Reply, ToolCall, ToolSpec, Usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeScriptExhausted(RuntimeError):
|
|
15
|
+
"""The code under test asked for more replies than were scripted."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def call(name: str, /, **arguments: Any) -> Reply:
|
|
19
|
+
"""Build a reply holding one tool call."""
|
|
20
|
+
return Reply(tool_calls=(ToolCall(id=f"call_{name}", name=name, arguments=arguments),))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FakeModel:
|
|
25
|
+
"""Replays replies in order and records every request it received."""
|
|
26
|
+
|
|
27
|
+
script: Iterable[Reply] = ()
|
|
28
|
+
name: str = "fake/scripted"
|
|
29
|
+
requests: list[tuple[list[Message], list[ToolSpec]]] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
self._replies = iter(self.script)
|
|
33
|
+
|
|
34
|
+
def complete(self, messages: Sequence[Message], tools: Sequence[ToolSpec]) -> Reply:
|
|
35
|
+
"""Return the next scripted reply."""
|
|
36
|
+
self.requests.append((list(messages), list(tools)))
|
|
37
|
+
try:
|
|
38
|
+
return next(self._replies)
|
|
39
|
+
except StopIteration:
|
|
40
|
+
raise FakeScriptExhausted(
|
|
41
|
+
f"{self.name}: script ran out after {len(self.requests) - 1} replies"
|
|
42
|
+
) from None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_file(cls, path: Path) -> FakeModel:
|
|
46
|
+
"""Load a script: a JSON list of {text, tool_calls: [{id, name, arguments}], usage}."""
|
|
47
|
+
replies = [
|
|
48
|
+
Reply(
|
|
49
|
+
text=item.get("text", ""),
|
|
50
|
+
tool_calls=tuple(ToolCall(**tc) for tc in item.get("tool_calls", [])),
|
|
51
|
+
usage=Usage(**item.get("usage", {})),
|
|
52
|
+
)
|
|
53
|
+
for item in json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
]
|
|
55
|
+
return cls(script=replies)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The default adapter. This is the only module that imports a provider library (NFR-5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from workflowval.models.base import Message, ModelError, Reply, ToolCall, ToolSpec, Usage
|
|
13
|
+
from workflowval.redact import redact_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_litellm() -> Any:
|
|
17
|
+
# Without this, importing litellm fetches its price list from the network.
|
|
18
|
+
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
|
19
|
+
import litellm
|
|
20
|
+
|
|
21
|
+
return litellm
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Seconds to wait before each new try. A temporary provider error shouldn't end a whole run.
|
|
25
|
+
RETRY_DELAYS = (2.0, 4.0, 8.0, 16.0, 32.0)
|
|
26
|
+
_TEMPORARY_ERRORS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"APIConnectionError",
|
|
29
|
+
"APITimeoutError",
|
|
30
|
+
"InternalServerError",
|
|
31
|
+
"RateLimitError",
|
|
32
|
+
"ServiceUnavailableError",
|
|
33
|
+
"Timeout",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_temporary(exc: Exception) -> bool:
|
|
39
|
+
"""True for rate limits, provider outages and dropped connections, which may clear."""
|
|
40
|
+
status = getattr(exc, "status_code", None)
|
|
41
|
+
if isinstance(status, int):
|
|
42
|
+
return status in (408, 429) or status >= 500
|
|
43
|
+
return type(exc).__name__ in _TEMPORARY_ERRORS
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _to_wire(message: Message) -> dict[str, Any]:
|
|
47
|
+
wire: dict[str, Any] = {"role": message.role, "content": message.content}
|
|
48
|
+
if message.tool_calls:
|
|
49
|
+
wire["tool_calls"] = [
|
|
50
|
+
{
|
|
51
|
+
"id": call.id,
|
|
52
|
+
"type": "function",
|
|
53
|
+
"function": {"name": call.name, "arguments": json.dumps(call.arguments)},
|
|
54
|
+
}
|
|
55
|
+
for call in message.tool_calls
|
|
56
|
+
]
|
|
57
|
+
if message.tool_call_id is not None:
|
|
58
|
+
wire["tool_call_id"] = message.tool_call_id
|
|
59
|
+
return wire
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _from_wire(raw: Any) -> ToolCall:
|
|
63
|
+
name = raw.function.name
|
|
64
|
+
try:
|
|
65
|
+
parsed = json.loads(raw.function.arguments or "{}")
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
return ToolCall(id=raw.id, name=name, parse_error=f"arguments are not valid JSON: {exc}")
|
|
68
|
+
if not isinstance(parsed, dict):
|
|
69
|
+
return ToolCall(id=raw.id, name=name, parse_error="arguments must be a JSON object")
|
|
70
|
+
return ToolCall(id=raw.id, name=name, arguments=parsed)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class LiteLLMModel:
|
|
75
|
+
"""Any provider LiteLLM supports, named `provider/model`. Keys come from env vars."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
temperature: float | None = None
|
|
79
|
+
seed: int | None = None
|
|
80
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
track_cost: bool = True
|
|
82
|
+
sleep: Callable[[float], None] = time.sleep
|
|
83
|
+
|
|
84
|
+
def complete(self, messages: Sequence[Message], tools: Sequence[ToolSpec]) -> Reply:
|
|
85
|
+
"""Call the provider and translate its reply. Failures raise a redacted ModelError."""
|
|
86
|
+
litellm = _load_litellm()
|
|
87
|
+
params: dict[str, Any] = {
|
|
88
|
+
"model": self.name,
|
|
89
|
+
"messages": [_to_wire(m) for m in messages],
|
|
90
|
+
# Drop params a provider doesn't support (e.g. seed) instead of failing the call.
|
|
91
|
+
"drop_params": True,
|
|
92
|
+
**self.extra,
|
|
93
|
+
}
|
|
94
|
+
if tools:
|
|
95
|
+
params["tools"] = [
|
|
96
|
+
{
|
|
97
|
+
"type": "function",
|
|
98
|
+
"function": {
|
|
99
|
+
"name": t.name,
|
|
100
|
+
"description": t.description,
|
|
101
|
+
"parameters": t.parameters,
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
for t in tools
|
|
105
|
+
]
|
|
106
|
+
if self.temperature is not None:
|
|
107
|
+
params["temperature"] = self.temperature
|
|
108
|
+
if self.seed is not None:
|
|
109
|
+
params["seed"] = self.seed
|
|
110
|
+
response = self._call_with_retries(litellm, params)
|
|
111
|
+
message = response.choices[0].message
|
|
112
|
+
usage = getattr(response, "usage", None)
|
|
113
|
+
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
|
114
|
+
output_tokens = getattr(usage, "completion_tokens", 0) or 0
|
|
115
|
+
return Reply(
|
|
116
|
+
text=message.content or "",
|
|
117
|
+
tool_calls=tuple(_from_wire(c) for c in message.tool_calls or ()),
|
|
118
|
+
usage=Usage(
|
|
119
|
+
input_tokens=input_tokens,
|
|
120
|
+
output_tokens=output_tokens,
|
|
121
|
+
cost_usd=self._cost(litellm, response, input_tokens + output_tokens),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _call_with_retries(self, litellm: Any, params: dict[str, Any]) -> Any:
|
|
126
|
+
delays = iter(RETRY_DELAYS)
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
return litellm.completion(**params)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
delay = next(delays, None) if _is_temporary(exc) else None
|
|
132
|
+
if delay is None:
|
|
133
|
+
# "from None": a chained provider exception could carry request headers.
|
|
134
|
+
detail = redact_text(str(exc))
|
|
135
|
+
raise ModelError(f"{self.name}: {type(exc).__name__}: {detail}") from None
|
|
136
|
+
self.sleep(delay)
|
|
137
|
+
|
|
138
|
+
def _cost(self, litellm: Any, response: Any, tokens: int) -> float | None:
|
|
139
|
+
"""Prefer the price the provider reported, since LiteLLM's estimate can be wrongly 0."""
|
|
140
|
+
if not self.track_cost:
|
|
141
|
+
return None
|
|
142
|
+
hidden = getattr(response, "_hidden_params", None) or {}
|
|
143
|
+
reported = hidden.get("response_cost")
|
|
144
|
+
if reported is None:
|
|
145
|
+
reported = getattr(getattr(response, "usage", None), "cost", None)
|
|
146
|
+
if reported is None:
|
|
147
|
+
try:
|
|
148
|
+
reported = litellm.completion_cost(completion_response=response)
|
|
149
|
+
except Exception:
|
|
150
|
+
return None # An unpriced model (e.g. a brand-new or local one) is not an error.
|
|
151
|
+
cost = float(reported)
|
|
152
|
+
# A call that used tokens wasn't free: the price is unknown, so don't report it as $0.
|
|
153
|
+
return None if cost == 0.0 and tokens else cost
|