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 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,4 @@
1
+ from .openai_compat import OpenAICompatible
2
+ from .opencode_cli import OpenCodeCLI
3
+
4
+ __all__ = ["OpenAICompatible", "OpenCodeCLI"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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)