llm-dev-core 0.3.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.
- llm_client/__init__.py +18 -0
- llm_client/cache.py +44 -0
- llm_client/client.py +246 -0
- llm_client/models.py +43 -0
- llm_client/pricing.py +22 -0
- llm_client/provider.py +28 -0
- llm_client/providers/__init__.py +4 -0
- llm_client/providers/openai_compat.py +86 -0
- llm_client/providers/opencode_cli.py +73 -0
- llm_client/replay.py +68 -0
- llm_client/span.py +33 -0
- llm_dev_core-0.3.0.dist-info/METADATA +9 -0
- llm_dev_core-0.3.0.dist-info/RECORD +17 -0
- llm_dev_core-0.3.0.dist-info/WHEEL +4 -0
- schema_validate/__init__.py +13 -0
- schema_validate/registry.py +44 -0
- schema_validate/validate.py +67 -0
llm_client/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .cache import ResponseCache
|
|
2
|
+
from .client import CostCapExceeded, LlmClient, StreamInterrupted
|
|
3
|
+
from .models import CompletionRequest, CompletionResult, TokenUsage, ValidationResult
|
|
4
|
+
from .replay import ReplayProvider
|
|
5
|
+
from .span import Span
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CostCapExceeded",
|
|
9
|
+
"LlmClient",
|
|
10
|
+
"ReplayProvider",
|
|
11
|
+
"ResponseCache",
|
|
12
|
+
"CompletionRequest",
|
|
13
|
+
"CompletionResult",
|
|
14
|
+
"TokenUsage",
|
|
15
|
+
"ValidationResult",
|
|
16
|
+
"Span",
|
|
17
|
+
"StreamInterrupted",
|
|
18
|
+
]
|
llm_client/cache.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .models import CompletionRequest, CompletionResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cache_key(request: CompletionRequest, prompt_version: str) -> str:
|
|
11
|
+
payload = {
|
|
12
|
+
"prompt_id": request.prompt_id,
|
|
13
|
+
"prompt_version": prompt_version,
|
|
14
|
+
"model_alias": request.model_alias,
|
|
15
|
+
"variables": request.variables,
|
|
16
|
+
}
|
|
17
|
+
raw = json.dumps(payload, sort_keys=True, default=str)
|
|
18
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResponseCache:
|
|
22
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
23
|
+
self._store: dict[str, CompletionResult] = {}
|
|
24
|
+
self._path = Path(path) if path else None
|
|
25
|
+
if self._path and self._path.is_file():
|
|
26
|
+
self._load()
|
|
27
|
+
|
|
28
|
+
def _load(self) -> None:
|
|
29
|
+
for line in self._path.read_text().splitlines():
|
|
30
|
+
if not line.strip():
|
|
31
|
+
continue
|
|
32
|
+
data = json.loads(line)
|
|
33
|
+
self._store[data["key"]] = CompletionResult.model_validate(data["result"])
|
|
34
|
+
|
|
35
|
+
def get(self, key: str) -> CompletionResult | None:
|
|
36
|
+
return self._store.get(key)
|
|
37
|
+
|
|
38
|
+
def put(self, key: str, result: CompletionResult) -> None:
|
|
39
|
+
self._store[key] = result
|
|
40
|
+
if self._path:
|
|
41
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with self._path.open("a") as handle:
|
|
43
|
+
entry = json.dumps({"key": key, "result": result.model_dump()}, default=str)
|
|
44
|
+
handle.write(entry + "\n")
|
llm_client/client.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .cache import ResponseCache, cache_key
|
|
10
|
+
from .models import CompletionRequest, CompletionResult, ValidationResult
|
|
11
|
+
from .pricing import cost_usd, resolve_model
|
|
12
|
+
from .provider import Provider, ProviderRequest
|
|
13
|
+
from .span import Span
|
|
14
|
+
|
|
15
|
+
Validator = Callable[[str, str | None], ValidationResult]
|
|
16
|
+
Renderer = Callable[[str, str, dict[str, Any]], list[dict]]
|
|
17
|
+
Emitter = Callable[[Span, CompletionResult | None], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CostCapExceeded(Exception):
|
|
21
|
+
def __init__(self, cost: Decimal, cap: Decimal, span_id: str) -> None:
|
|
22
|
+
super().__init__(f"cost {cost} usd > cap {cap} usd")
|
|
23
|
+
self.cost = cost
|
|
24
|
+
self.cap = cap
|
|
25
|
+
self.span_id = span_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StreamInterrupted(Exception):
|
|
29
|
+
def __init__(self, partial_text: str) -> None:
|
|
30
|
+
super().__init__("stream interrumpido tras entregar texto")
|
|
31
|
+
self.partial_text = partial_text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _noop_emitter(_span: Span, _result: CompletionResult | None) -> None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LlmClient:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
provider: Provider,
|
|
42
|
+
*,
|
|
43
|
+
consumer_repo: str = "",
|
|
44
|
+
model_aliases: dict[str, str] | None = None,
|
|
45
|
+
retries: int = 3,
|
|
46
|
+
repair_cap: int = 2,
|
|
47
|
+
retry_base_ms: int = 300,
|
|
48
|
+
retry_jitter_ms: int = 150,
|
|
49
|
+
cache: ResponseCache | None = None,
|
|
50
|
+
emitter: Emitter | None = None,
|
|
51
|
+
cost_cap_usd: Decimal | None = None,
|
|
52
|
+
cost_fn: Callable[[str, int | None, int | None], Decimal] = cost_usd,
|
|
53
|
+
validator: Validator | None = None,
|
|
54
|
+
renderer: Renderer | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.provider = provider
|
|
57
|
+
self.consumer_repo = consumer_repo
|
|
58
|
+
self.model_aliases = model_aliases
|
|
59
|
+
self.retries = retries
|
|
60
|
+
self.repair_cap = repair_cap
|
|
61
|
+
self.retry_base_ms = retry_base_ms
|
|
62
|
+
self.retry_jitter_ms = retry_jitter_ms
|
|
63
|
+
self.cache = cache
|
|
64
|
+
self.emitter = emitter or _noop_emitter
|
|
65
|
+
self.cost_cap_usd = cost_cap_usd
|
|
66
|
+
self.cost_fn = cost_fn
|
|
67
|
+
self.validator = validator or (lambda _text, _schema: ValidationResult(ok=True))
|
|
68
|
+
self.renderer = renderer or self._default_renderer
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _default_renderer(prompt_id: str, prompt_version: str, variables: dict[str, Any]) -> list[dict]:
|
|
72
|
+
body = {"prompt_id": prompt_id, "prompt_version": prompt_version, "variables": variables}
|
|
73
|
+
return [{"role": "user", "content": json.dumps(body, ensure_ascii=False)}]
|
|
74
|
+
|
|
75
|
+
def _render(self, request: CompletionRequest, prompt_version: str, errors: list[str] | None = None) -> list[dict]:
|
|
76
|
+
messages = self.renderer(request.prompt_id, prompt_version, request.variables)
|
|
77
|
+
if errors:
|
|
78
|
+
messages.append({"role": "assistant", "content": json.dumps({"validation_errors": errors})})
|
|
79
|
+
messages.append({"role": "user", "content": "Corrige siguiendo el schema."})
|
|
80
|
+
return messages
|
|
81
|
+
|
|
82
|
+
def complete(self, request: CompletionRequest) -> CompletionResult:
|
|
83
|
+
return self._execute(request, stream=False)
|
|
84
|
+
|
|
85
|
+
def stream(self, request: CompletionRequest) -> CompletionResult:
|
|
86
|
+
return self._execute(request, stream=True)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _collect_stream(stream) -> tuple[str, None]:
|
|
90
|
+
parts: list[str] = []
|
|
91
|
+
try:
|
|
92
|
+
for chunk in stream:
|
|
93
|
+
parts.append(chunk)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
raise StreamInterrupted("".join(parts)) from exc
|
|
96
|
+
return "".join(parts), None
|
|
97
|
+
|
|
98
|
+
def _execute(self, request: CompletionRequest, stream: bool) -> CompletionResult:
|
|
99
|
+
start = time.perf_counter()
|
|
100
|
+
prompt_version = request.prompt_version or "0.0.0"
|
|
101
|
+
model = resolve_model(request.model_alias, self.model_aliases)
|
|
102
|
+
span = Span(
|
|
103
|
+
consumer_repo=self.consumer_repo,
|
|
104
|
+
prompt_id=request.prompt_id,
|
|
105
|
+
prompt_version=prompt_version,
|
|
106
|
+
model_alias=request.model_alias,
|
|
107
|
+
model=model,
|
|
108
|
+
provider=self.provider.name,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if self.cache is not None:
|
|
112
|
+
key = cache_key(request, prompt_version)
|
|
113
|
+
hit = self.cache.get(key)
|
|
114
|
+
if hit is not None:
|
|
115
|
+
span.cache_hit = True
|
|
116
|
+
span.call_skipped = True
|
|
117
|
+
span.status = "skipped"
|
|
118
|
+
span.latency_ms = 0
|
|
119
|
+
self.emitter(span, hit)
|
|
120
|
+
return hit
|
|
121
|
+
|
|
122
|
+
attempts = 0
|
|
123
|
+
repaired = 0
|
|
124
|
+
last_error: Exception | None = None
|
|
125
|
+
earlier_attempt_had_text = False
|
|
126
|
+
final_path = "validation_failed"
|
|
127
|
+
final_validation: ValidationResult | None = None
|
|
128
|
+
final_text = ""
|
|
129
|
+
final_cost = Decimal("0")
|
|
130
|
+
final_usage = None
|
|
131
|
+
|
|
132
|
+
while True:
|
|
133
|
+
attempts += 1
|
|
134
|
+
if attempts > 1 + self.retries + self.repair_cap:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
errors = final_validation.errors if final_validation is not None and not final_validation.ok else None
|
|
138
|
+
messages = self._render(request, prompt_version, errors)
|
|
139
|
+
provider_request = ProviderRequest(
|
|
140
|
+
model=model,
|
|
141
|
+
messages=messages,
|
|
142
|
+
temperature=request.temperature,
|
|
143
|
+
max_tokens=request.max_tokens,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
if stream:
|
|
148
|
+
text, usage = self._collect_stream(self.provider.stream(provider_request))
|
|
149
|
+
else:
|
|
150
|
+
response = self.provider.complete(provider_request)
|
|
151
|
+
text, usage = response.text, response.usage
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
if isinstance(exc, StreamInterrupted) and exc.partial_text.strip():
|
|
154
|
+
earlier_attempt_had_text = True
|
|
155
|
+
last_error = exc
|
|
156
|
+
if attempts > 1 + self.retries:
|
|
157
|
+
break
|
|
158
|
+
wait = self.retry_base_ms * (2 ** (attempts - 1)) + random.uniform(0, self.retry_jitter_ms)
|
|
159
|
+
time.sleep(wait / 1000)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
had_text = bool(text.strip())
|
|
163
|
+
validation = self.validator(text, request.response_schema)
|
|
164
|
+
|
|
165
|
+
if validation.ok:
|
|
166
|
+
span.latency_ms = int((time.perf_counter() - start) * 1000)
|
|
167
|
+
span.tokens_input = usage.input_tokens if usage else None
|
|
168
|
+
span.tokens_output = usage.output_tokens if usage else None
|
|
169
|
+
span.cost_usd = self.cost_fn(model, span.tokens_input, span.tokens_output)
|
|
170
|
+
span.retry_count = attempts - 1
|
|
171
|
+
span.repaired_attempts = repaired
|
|
172
|
+
span.status = "repaired" if repaired else "ok"
|
|
173
|
+
span.retry_unnecessary = attempts > 1 and earlier_attempt_had_text
|
|
174
|
+
if (
|
|
175
|
+
span.cost_usd is not None
|
|
176
|
+
and self.cost_cap_usd is not None
|
|
177
|
+
and span.cost_usd > self.cost_cap_usd
|
|
178
|
+
):
|
|
179
|
+
span.status = "blocked"
|
|
180
|
+
span.error_type = "cost_cap_exceeded"
|
|
181
|
+
self.emitter(span, None)
|
|
182
|
+
raise CostCapExceeded(span.cost_usd, self.cost_cap_usd, span.span_id)
|
|
183
|
+
result = self._build_result(request, prompt_version, model, span, text, validation, usage)
|
|
184
|
+
if self.cache is not None:
|
|
185
|
+
self.cache.put(key, result)
|
|
186
|
+
self.emitter(span, result)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
earlier_attempt_had_text = earlier_attempt_had_text or had_text
|
|
190
|
+
|
|
191
|
+
if repaired < self.repair_cap:
|
|
192
|
+
repaired += 1
|
|
193
|
+
final_validation = validation
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
final_path = "validation_failed"
|
|
197
|
+
final_validation = validation
|
|
198
|
+
final_text = text
|
|
199
|
+
final_usage = usage
|
|
200
|
+
final_cost = self.cost_fn(model, usage.input_tokens if usage else None, usage.output_tokens if usage else None)
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
span.latency_ms = int((time.perf_counter() - start) * 1000)
|
|
204
|
+
span.retry_count = attempts - 1
|
|
205
|
+
span.repaired_attempts = repaired
|
|
206
|
+
span.cost_usd = final_cost
|
|
207
|
+
span.tokens_input = final_usage.input_tokens if final_usage else None
|
|
208
|
+
span.tokens_output = final_usage.output_tokens if final_usage else None
|
|
209
|
+
span.status = "failed"
|
|
210
|
+
span.error_type = type(last_error).__name__ if last_error is not None else "validation"
|
|
211
|
+
result = self._build_result(
|
|
212
|
+
request,
|
|
213
|
+
prompt_version,
|
|
214
|
+
model,
|
|
215
|
+
span,
|
|
216
|
+
final_text,
|
|
217
|
+
final_validation or ValidationResult(ok=False, errors=["sin respuesta"]),
|
|
218
|
+
final_usage,
|
|
219
|
+
)
|
|
220
|
+
self.emitter(span, result)
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
def _build_result(
|
|
224
|
+
self,
|
|
225
|
+
request: CompletionRequest,
|
|
226
|
+
prompt_version: str,
|
|
227
|
+
model: str,
|
|
228
|
+
span: Span,
|
|
229
|
+
text: str,
|
|
230
|
+
validation: ValidationResult,
|
|
231
|
+
usage: Any,
|
|
232
|
+
) -> CompletionResult:
|
|
233
|
+
return CompletionResult(
|
|
234
|
+
raw_text=text,
|
|
235
|
+
parsed=getattr(validation, "parsed", None),
|
|
236
|
+
validation=validation,
|
|
237
|
+
usage=usage,
|
|
238
|
+
cost_usd=span.cost_usd or Decimal("0"),
|
|
239
|
+
latency_ms=span.latency_ms or 0,
|
|
240
|
+
prompt_id=request.prompt_id,
|
|
241
|
+
prompt_version=prompt_version,
|
|
242
|
+
model_alias=request.model_alias,
|
|
243
|
+
model=model,
|
|
244
|
+
provider=self.provider.name,
|
|
245
|
+
span_id=span.span_id,
|
|
246
|
+
)
|
llm_client/models.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenUsage(BaseModel):
|
|
10
|
+
input_tokens: int
|
|
11
|
+
output_tokens: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationResult(BaseModel):
|
|
15
|
+
ok: bool
|
|
16
|
+
errors: list[str] = Field(default_factory=list)
|
|
17
|
+
parsed: Any = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CompletionRequest(BaseModel):
|
|
21
|
+
prompt_id: str
|
|
22
|
+
variables: dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
model_alias: str
|
|
24
|
+
prompt_version: str | None = None
|
|
25
|
+
response_schema: str | None = None
|
|
26
|
+
temperature: float | None = None
|
|
27
|
+
max_tokens: int | None = None
|
|
28
|
+
tags: list[str] = Field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompletionResult(BaseModel):
|
|
32
|
+
raw_text: str
|
|
33
|
+
parsed: Any = None
|
|
34
|
+
validation: ValidationResult
|
|
35
|
+
usage: TokenUsage | None = None
|
|
36
|
+
cost_usd: Decimal
|
|
37
|
+
latency_ms: int
|
|
38
|
+
prompt_id: str
|
|
39
|
+
prompt_version: str
|
|
40
|
+
model_alias: str
|
|
41
|
+
model: str
|
|
42
|
+
provider: str
|
|
43
|
+
span_id: str
|
llm_client/pricing.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
MODEL_PRICES_USD_PER_1K: dict[str, tuple[float, float]] = {
|
|
6
|
+
"big-pickle": (0.0, 0.0),
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
DEFAULT_ALIASES: dict[str, str] = {"fast": "big-pickle"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_model(model_alias: str, aliases: dict[str, str] | None = None) -> str:
|
|
13
|
+
table = DEFAULT_ALIASES if aliases is None else aliases
|
|
14
|
+
return table.get(model_alias, model_alias)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cost_usd(model: str, input_tokens: int | None, output_tokens: int | None) -> Decimal:
|
|
18
|
+
if input_tokens is None or output_tokens is None:
|
|
19
|
+
return Decimal("0")
|
|
20
|
+
in_price, out_price = MODEL_PRICES_USD_PER_1K.get(model, (0.0, 0.0))
|
|
21
|
+
value = input_tokens / 1000 * in_price + output_tokens / 1000 * out_price
|
|
22
|
+
return Decimal(str(round(value, 6)))
|
llm_client/provider.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Iterator, Protocol
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from .models import TokenUsage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProviderRequest(BaseModel):
|
|
11
|
+
model: str
|
|
12
|
+
messages: list[dict] = Field(default_factory=list)
|
|
13
|
+
temperature: float | None = None
|
|
14
|
+
max_tokens: int | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderResponse(BaseModel):
|
|
18
|
+
text: str
|
|
19
|
+
model: str
|
|
20
|
+
usage: TokenUsage | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Provider(Protocol):
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def complete(self, request: ProviderRequest) -> ProviderResponse: ...
|
|
27
|
+
|
|
28
|
+
def stream(self, request: ProviderRequest) -> Iterator[str]: ...
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Iterator
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ..models import TokenUsage
|
|
8
|
+
from ..provider import ProviderRequest, ProviderResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAICompatible:
|
|
12
|
+
name = "openai-compatible"
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
base_url: str,
|
|
17
|
+
*,
|
|
18
|
+
api_key: str | None = None,
|
|
19
|
+
model: str = "gpt-4o-mini",
|
|
20
|
+
client: httpx.Client | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.base_url = base_url.rstrip("/")
|
|
23
|
+
self.api_key = api_key
|
|
24
|
+
self.model = model
|
|
25
|
+
self._client = client or httpx.Client(timeout=60)
|
|
26
|
+
|
|
27
|
+
def _headers(self) -> dict:
|
|
28
|
+
headers = {"Content-Type": "application/json"}
|
|
29
|
+
if self.api_key:
|
|
30
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
31
|
+
return headers
|
|
32
|
+
|
|
33
|
+
def _payload(self, request: ProviderRequest, stream: bool) -> dict:
|
|
34
|
+
payload = {
|
|
35
|
+
"model": request.model or self.model,
|
|
36
|
+
"messages": request.messages,
|
|
37
|
+
"stream": stream,
|
|
38
|
+
}
|
|
39
|
+
if request.temperature is not None:
|
|
40
|
+
payload["temperature"] = request.temperature
|
|
41
|
+
if request.max_tokens is not None:
|
|
42
|
+
payload["max_tokens"] = request.max_tokens
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
def complete(self, request: ProviderRequest) -> ProviderResponse:
|
|
46
|
+
response = self._client.post(
|
|
47
|
+
f"{self.base_url}/chat/completions",
|
|
48
|
+
headers=self._headers(),
|
|
49
|
+
json=self._payload(request, stream=False),
|
|
50
|
+
)
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
data = response.json()
|
|
53
|
+
text = data["choices"][0]["message"]["content"] or ""
|
|
54
|
+
usage = data.get("usage")
|
|
55
|
+
tokens = (
|
|
56
|
+
TokenUsage(
|
|
57
|
+
input_tokens=usage["prompt_tokens"],
|
|
58
|
+
output_tokens=usage["completion_tokens"],
|
|
59
|
+
)
|
|
60
|
+
if usage
|
|
61
|
+
else None
|
|
62
|
+
)
|
|
63
|
+
return ProviderResponse(
|
|
64
|
+
text=text,
|
|
65
|
+
model=data.get("model", request.model),
|
|
66
|
+
usage=tokens,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def stream(self, request: ProviderRequest) -> Iterator[str]:
|
|
70
|
+
with self._client.stream(
|
|
71
|
+
"POST",
|
|
72
|
+
f"{self.base_url}/chat/completions",
|
|
73
|
+
headers=self._headers(),
|
|
74
|
+
json=self._payload(request, stream=True),
|
|
75
|
+
) as response:
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
for line in response.iter_lines():
|
|
78
|
+
if not line or not line.startswith("data: "):
|
|
79
|
+
continue
|
|
80
|
+
chunk = line[6:]
|
|
81
|
+
if chunk == "[DONE]":
|
|
82
|
+
break
|
|
83
|
+
data = __import__("json").loads(chunk)
|
|
84
|
+
delta = data["choices"][0]["delta"].get("content")
|
|
85
|
+
if delta:
|
|
86
|
+
yield delta
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Iterator
|
|
6
|
+
|
|
7
|
+
from ..models import TokenUsage
|
|
8
|
+
from ..provider import ProviderRequest, ProviderResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenCodeCLI:
|
|
12
|
+
name = "opencode"
|
|
13
|
+
|
|
14
|
+
def __init__(self, model: str, *, cwd: str | None = None, binary: str = "opencode") -> None:
|
|
15
|
+
self.model = model
|
|
16
|
+
self.cwd = cwd
|
|
17
|
+
self.binary = binary
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _render_prompt(request: ProviderRequest) -> str:
|
|
21
|
+
return "\n\n".join(f"{m['role']}:\n{m['content']}" for m in request.messages)
|
|
22
|
+
|
|
23
|
+
def _spawn(self, prompt: str) -> subprocess.Popen:
|
|
24
|
+
cmd = [self.binary, "run", "--format", "json", "--model", self.model, prompt]
|
|
25
|
+
return subprocess.Popen(
|
|
26
|
+
cmd,
|
|
27
|
+
stdout=subprocess.PIPE,
|
|
28
|
+
stderr=subprocess.DEVNULL,
|
|
29
|
+
text=True,
|
|
30
|
+
cwd=self.cwd,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _parse(line: str) -> tuple[str, int | None, str | None]:
|
|
35
|
+
try:
|
|
36
|
+
event = json.loads(line)
|
|
37
|
+
except (json.JSONDecodeError, TypeError):
|
|
38
|
+
return "", None, None
|
|
39
|
+
event_type = event.get("type")
|
|
40
|
+
part = event.get("part") or {}
|
|
41
|
+
if part.get("type") == "text":
|
|
42
|
+
return part.get("text", ""), None, None
|
|
43
|
+
if event_type in ("step-finish", "step_finish"):
|
|
44
|
+
tokens = part.get("tokens") or {}
|
|
45
|
+
input_tokens = tokens.get("input")
|
|
46
|
+
output_tokens = tokens.get("output")
|
|
47
|
+
if input_tokens is not None and output_tokens is not None:
|
|
48
|
+
usage = {"input": input_tokens, "output": output_tokens}
|
|
49
|
+
return "", usage, part.get("cost")
|
|
50
|
+
return "", None, None
|
|
51
|
+
|
|
52
|
+
def complete(self, request: ProviderRequest) -> ProviderResponse:
|
|
53
|
+
proc = self._spawn(self._render_prompt(request))
|
|
54
|
+
text_parts: list[str] = []
|
|
55
|
+
usage: TokenUsage | None = None
|
|
56
|
+
for line in iter(proc.stdout.readline, ""):
|
|
57
|
+
current, usage_data, _ = self._parse(line)
|
|
58
|
+
if current:
|
|
59
|
+
text_parts.append(current)
|
|
60
|
+
if usage_data:
|
|
61
|
+
usage = TokenUsage(input_tokens=usage_data["input"], output_tokens=usage_data["output"])
|
|
62
|
+
proc.wait()
|
|
63
|
+
return ProviderResponse(text="".join(text_parts), model=self.model, usage=usage)
|
|
64
|
+
|
|
65
|
+
def stream(self, request: ProviderRequest) -> Iterator[str]:
|
|
66
|
+
proc = self._spawn(self._render_prompt(request))
|
|
67
|
+
try:
|
|
68
|
+
for line in proc.stdout:
|
|
69
|
+
current, _, _ = self._parse(line)
|
|
70
|
+
if current:
|
|
71
|
+
yield current
|
|
72
|
+
finally:
|
|
73
|
+
proc.wait()
|
llm_client/replay.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .provider import Provider, ProviderRequest, ProviderResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReplayProvider:
|
|
10
|
+
name = "replay"
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
cassette_dir: str | Path,
|
|
15
|
+
*,
|
|
16
|
+
record: bool = False,
|
|
17
|
+
inner: Provider | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.dir = Path(cassette_dir)
|
|
20
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
self.record = record
|
|
22
|
+
self.inner = inner
|
|
23
|
+
|
|
24
|
+
def _cassette(self, request: ProviderRequest, stream: bool) -> Path:
|
|
25
|
+
safe_model = request.model.replace("/", "--")
|
|
26
|
+
slug = f"{'stream' if stream else 'complete'}-{safe_model}-{len(request.messages)}"
|
|
27
|
+
return self.dir / f"{slug}.jsonl"
|
|
28
|
+
|
|
29
|
+
def complete(self, request: ProviderRequest) -> ProviderResponse:
|
|
30
|
+
if not self.record:
|
|
31
|
+
entry = self._read(self._cassette(request, stream=False), request)
|
|
32
|
+
return ProviderResponse.model_validate(entry["response"])
|
|
33
|
+
if self.inner is None:
|
|
34
|
+
raise RuntimeError("ReplayProvider en modo record requiere inner")
|
|
35
|
+
response = self.inner.complete(request)
|
|
36
|
+
self._append(self._cassette(request, stream=False), request, response)
|
|
37
|
+
return response
|
|
38
|
+
|
|
39
|
+
def stream(self, request: ProviderRequest) -> list[str]:
|
|
40
|
+
if not self.record:
|
|
41
|
+
entry = self._read(self._cassette(request, stream=True), request)
|
|
42
|
+
return list(entry["chunks"])
|
|
43
|
+
if self.inner is None:
|
|
44
|
+
raise RuntimeError("ReplayProvider en modo record requiere inner")
|
|
45
|
+
chunks = list(self.inner.stream(request))
|
|
46
|
+
self._append_chunks(self._cassette(request, stream=True), request, chunks)
|
|
47
|
+
return chunks
|
|
48
|
+
|
|
49
|
+
def _read(self, path: Path, request: ProviderRequest) -> dict:
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
raise FileNotFoundError(f"cassette no encontrado: {path}")
|
|
52
|
+
for line in path.read_text().splitlines():
|
|
53
|
+
if not line.strip():
|
|
54
|
+
continue
|
|
55
|
+
entry = json.loads(line)
|
|
56
|
+
if entry["request"] == request.messages and entry.get("model") == request.model:
|
|
57
|
+
return entry
|
|
58
|
+
raise KeyError(f"cassette sin entrada para el request: {path}")
|
|
59
|
+
|
|
60
|
+
def _append(self, path: Path, request: ProviderRequest, response: ProviderResponse) -> None:
|
|
61
|
+
with path.open("a") as handle:
|
|
62
|
+
entry = {"model": request.model, "request": request.messages, "response": response.model_dump()}
|
|
63
|
+
handle.write(json.dumps(entry, default=str) + "\n")
|
|
64
|
+
|
|
65
|
+
def _append_chunks(self, path: Path, request: ProviderRequest, chunks: list[str]) -> None:
|
|
66
|
+
with path.open("a") as handle:
|
|
67
|
+
entry = {"model": request.model, "request": request.messages, "chunks": chunks}
|
|
68
|
+
handle.write(json.dumps(entry, default=str) + "\n")
|
llm_client/span.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Span(BaseModel):
|
|
11
|
+
span_id: str = Field(default_factory=lambda: uuid4().hex)
|
|
12
|
+
trace_id: str = Field(default_factory=lambda: uuid4().hex)
|
|
13
|
+
consumer_repo: str
|
|
14
|
+
prompt_id: str
|
|
15
|
+
prompt_version: str
|
|
16
|
+
model_alias: str
|
|
17
|
+
model: str
|
|
18
|
+
provider: str
|
|
19
|
+
tokens_input: int | None = None
|
|
20
|
+
tokens_output: int | None = None
|
|
21
|
+
latency_ms: int | None = None
|
|
22
|
+
cost_usd: Decimal | None = None
|
|
23
|
+
retry_count: int = 0
|
|
24
|
+
cache_hit: bool = False
|
|
25
|
+
status: str = "ok"
|
|
26
|
+
call_skipped: bool = False
|
|
27
|
+
retry_unnecessary: bool = False
|
|
28
|
+
error_type: str | None = None
|
|
29
|
+
repaired_attempts: int = 0
|
|
30
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
31
|
+
|
|
32
|
+
def as_jsonl(self) -> str:
|
|
33
|
+
return self.model_dump_json()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llm-dev-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Núcleo versionado del que dependen 52 proyectos en 52 semanas: un solo sistema acumulativo.
|
|
5
|
+
Author-email: binahco <binahco.sas@gmail.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.7
|
|
9
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
llm_client/__init__.py,sha256=ZjQwe1sJ_x0xmcBQ3656NZuF7SVwBApJNr3rn5hndxU,468
|
|
2
|
+
llm_client/cache.py,sha256=D6pMfz6gnDV4_laSfUUPFjgGcO-aUilrUowcBHTdmjI,1512
|
|
3
|
+
llm_client/client.py,sha256=xB1yYtBreswGxKTXaa1SKLN9ZYNL-SMfFnC_McIED1w,9565
|
|
4
|
+
llm_client/models.py,sha256=QjLj5Uo0Jluv9UJ2MxtDP6Y2-7Fc94elEFxBGPi4jfI,965
|
|
5
|
+
llm_client/pricing.py,sha256=-heWiPQzVlYyI7g9FlZGv43-l0IMaft5vZcMPoaRYe8,772
|
|
6
|
+
llm_client/provider.py,sha256=j4xmOdg0AGfmt7yOJ9fyTsMOelRJl4oLsZNzGzYOf6U,610
|
|
7
|
+
llm_client/replay.py,sha256=oiqRgmK4Q4hO78wHef7D2AJGQ20VZIVvWIg7ax7cXos,2847
|
|
8
|
+
llm_client/span.py,sha256=uBf6W2zWLrZ2ZcgqbdDc2EhP8Ox0cCAOm9ip323eY5s,908
|
|
9
|
+
llm_client/providers/__init__.py,sha256=7tt8Cb71CPrizUQTKomAFxGa1qfhwEJAoWWfUd3V8zc,128
|
|
10
|
+
llm_client/providers/openai_compat.py,sha256=C_s0KfQIFfjncGetHbnFjE-Yi2yNm9hWOj0AzZcsrSE,2756
|
|
11
|
+
llm_client/providers/opencode_cli.py,sha256=FA4DOoxrVMej-6XO5AxkfHJKABmHXUXLR0Y_CG_Iros,2646
|
|
12
|
+
schema_validate/__init__.py,sha256=kM6of60n3UYD0-QBi3jeTbpIONSP0YEu7fmN7GDK0aE,350
|
|
13
|
+
schema_validate/registry.py,sha256=DkdDaKyRPUWvuB2Pb4oej2yzT3wuIWha2iBazKRMRVw,1238
|
|
14
|
+
schema_validate/validate.py,sha256=RREk7wUVDTHelH_R6bVdXUzzD4bt3BUwUkZxS0rH6rE,2219
|
|
15
|
+
llm_dev_core-0.3.0.dist-info/METADATA,sha256=Jmud1jPeLl6oBEdpLrINgno1aX1Uxa92nvOjwuqnDew,314
|
|
16
|
+
llm_dev_core-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
17
|
+
llm_dev_core-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .registry import SchemaRegistry, default_registry, make_validator, register
|
|
2
|
+
from .validate import SchemaNotFound, extract_json, strip_json_fence, validate_text
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"SchemaNotFound",
|
|
6
|
+
"SchemaRegistry",
|
|
7
|
+
"default_registry",
|
|
8
|
+
"extract_json",
|
|
9
|
+
"make_validator",
|
|
10
|
+
"register",
|
|
11
|
+
"strip_json_fence",
|
|
12
|
+
"validate_text",
|
|
13
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from llm_client import ValidationResult
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .validate import M, SchemaNotFound, validate_text
|
|
9
|
+
|
|
10
|
+
Validator = Callable[[str, str | None], ValidationResult]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchemaRegistry:
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._schemas: dict[str, type[BaseModel]] = {}
|
|
16
|
+
|
|
17
|
+
def register(self, schema_id: str, model: type[M]) -> None:
|
|
18
|
+
self._schemas[schema_id] = model
|
|
19
|
+
|
|
20
|
+
def get(self, schema_id: str) -> type[BaseModel]:
|
|
21
|
+
try:
|
|
22
|
+
return self._schemas[schema_id]
|
|
23
|
+
except KeyError:
|
|
24
|
+
raise SchemaNotFound(schema_id) from None
|
|
25
|
+
|
|
26
|
+
def make_validator(self, schema_id: str) -> Validator:
|
|
27
|
+
"""Devuelve un callable válido para el hook `validator` de llm-client."""
|
|
28
|
+
model = self.get(schema_id)
|
|
29
|
+
|
|
30
|
+
def validator(text: str, schema: str | None = None) -> ValidationResult:
|
|
31
|
+
return validate_text(model, text)
|
|
32
|
+
|
|
33
|
+
return validator
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
default_registry = SchemaRegistry()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def register(schema_id: str, model: type[M]) -> None:
|
|
40
|
+
default_registry.register(schema_id, model)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_validator(schema_id: str) -> Validator:
|
|
44
|
+
return default_registry.make_validator(schema_id)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from llm_client import ValidationResult
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
M = TypeVar("M", bound=BaseModel)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchemaNotFound(KeyError):
|
|
14
|
+
def __init__(self, schema_id: str) -> None:
|
|
15
|
+
super().__init__(f"schema no registrado: {schema_id!r}")
|
|
16
|
+
self.schema_id = schema_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def strip_json_fence(text: str) -> str:
|
|
20
|
+
"""Quita caretas ```json … ``` cuando el modelo las añade (JSON mode no las garantiza)."""
|
|
21
|
+
match = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
|
|
22
|
+
return match.group(1).strip() if match else text.strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_json(text: str) -> Any:
|
|
26
|
+
"""Parsea JSON: con fences, sin fences, o recortando al primer/last '{'/'['."""
|
|
27
|
+
candidate = strip_json_fence(text)
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(candidate)
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
pass
|
|
32
|
+
start = min(
|
|
33
|
+
(candidate.find(ch) for ch in "{[" if candidate.find(ch) != -1),
|
|
34
|
+
default=-1,
|
|
35
|
+
)
|
|
36
|
+
end = max(
|
|
37
|
+
(candidate.rfind(ch) for ch in "}]" if candidate.rfind(ch) != -1),
|
|
38
|
+
default=-1,
|
|
39
|
+
)
|
|
40
|
+
if start == -1 or end <= start:
|
|
41
|
+
raise ValueError("no se encontró JSON en la respuesta")
|
|
42
|
+
return json.loads(candidate[start : end + 1])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _describe_errors(exc: ValidationError) -> list[str]:
|
|
46
|
+
errors: list[str] = []
|
|
47
|
+
for entry in exc.errors():
|
|
48
|
+
loc = ".".join(str(part) for part in entry.get("loc", ())) or "<raiz>"
|
|
49
|
+
errors.append(f"{loc}: {entry.get('msg')}")
|
|
50
|
+
return errors
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_text(model: type[M], text: str) -> ValidationResult:
|
|
54
|
+
"""Valida el texto crudo del LLM contra un modelo Pydantic.
|
|
55
|
+
|
|
56
|
+
Devuelve un ValidationResult (contrato de llm-client) con `parsed` poblado
|
|
57
|
+
solo cuando la validación pasa.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
data = extract_json(text)
|
|
61
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
62
|
+
return ValidationResult(ok=False, errors=[f"JSON inválido: {exc}"])
|
|
63
|
+
try:
|
|
64
|
+
parsed = model.model_validate(data)
|
|
65
|
+
except ValidationError as exc:
|
|
66
|
+
return ValidationResult(ok=False, errors=_describe_errors(exc))
|
|
67
|
+
return ValidationResult(ok=True, errors=[], parsed=parsed)
|