minifmt 1.3.3__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.
- minifmt/__init__.py +58 -0
- minifmt/__main__.py +3 -0
- minifmt/ai/__init__.py +15 -0
- minifmt/ai/adapters/__init__.py +27 -0
- minifmt/ai/adapters/anthropic_adapter.py +72 -0
- minifmt/ai/adapters/base.py +107 -0
- minifmt/ai/adapters/deepseek_adapter.py +29 -0
- minifmt/ai/adapters/groq_adapter.py +63 -0
- minifmt/ai/adapters/http.py +80 -0
- minifmt/ai/adapters/openai_adapter.py +79 -0
- minifmt/ai/adapters/simulated.py +334 -0
- minifmt/ai/repair.py +372 -0
- minifmt/bench.py +294 -0
- minifmt/cli.py +426 -0
- minifmt/codec.py +183 -0
- minifmt/contract.py +320 -0
- minifmt/domain.py +949 -0
- minifmt/errors.py +62 -0
- minifmt/integration.py +272 -0
- minifmt/onboarding.py +242 -0
- minifmt/parser.py +336 -0
- minifmt/prompt.py +118 -0
- minifmt/registry.py +103 -0
- minifmt/schema.py +575 -0
- minifmt/serializer.py +170 -0
- minifmt/stream.py +426 -0
- minifmt/tokens.py +137 -0
- minifmt/values.py +203 -0
- minifmt/workflow.mjs +59 -0
- minifmt/workflow.py +406 -0
- minifmt-1.3.3.dist-info/METADATA +89 -0
- minifmt-1.3.3.dist-info/RECORD +36 -0
- minifmt-1.3.3.dist-info/WHEEL +5 -0
- minifmt-1.3.3.dist-info/entry_points.txt +2 -0
- minifmt-1.3.3.dist-info/licenses/LICENSE +21 -0
- minifmt-1.3.3.dist-info/top_level.txt +1 -0
minifmt/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
""".mini — forkable, token-efficient notation for LLM structured outputs.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
>>> from minifmt import Registry, parse, dumps, roundtrip_ok
|
|
6
|
+
>>> reg = Registry.load() # discovers forks/*/contract.json
|
|
7
|
+
>>> doc = parse(text, reg.get("a")) # -> Document (canonical JSON via .to_canonical())
|
|
8
|
+
>>> text2 = dumps(doc.to_canonical(), reg.get("a"))
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict
|
|
13
|
+
|
|
14
|
+
from .contract import Contract, Field
|
|
15
|
+
from .errors import MiniError, MiniValidationError
|
|
16
|
+
from .parser import Document, detect_prefix, parse
|
|
17
|
+
from .prompt import parser_prompt, spec_block
|
|
18
|
+
from .registry import Registry
|
|
19
|
+
from .schema import from_json_schema, from_pydantic, to_json_schema
|
|
20
|
+
from .serializer import dumps
|
|
21
|
+
from .stream import Reader, ReaderResult, StreamRecord, create_reader, read_records
|
|
22
|
+
from .values import scalar_equal
|
|
23
|
+
|
|
24
|
+
__version__ = "1.3.3"
|
|
25
|
+
SPEC_VERSION = "1.1"
|
|
26
|
+
|
|
27
|
+
__all__ = ["Contract", "Field", "MiniError", "MiniValidationError", "Document", "parse",
|
|
28
|
+
"dumps", "detect_prefix", "Registry", "spec_block", "parser_prompt",
|
|
29
|
+
"canonical_equal", "roundtrip_ok", "Reader", "ReaderResult", "StreamRecord", "create_reader",
|
|
30
|
+
"read_records", "from_json_schema", "to_json_schema", "from_pydantic", "__version__", "SPEC_VERSION"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_equal(a: Any, b: Any) -> bool:
|
|
34
|
+
"""Structural equality tolerant to int/float representation differences."""
|
|
35
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
36
|
+
return a.keys() == b.keys() and all(canonical_equal(a[k], b[k]) for k in a)
|
|
37
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
38
|
+
return len(a) == len(b) and all(canonical_equal(x, y) for x, y in zip(a, b))
|
|
39
|
+
return scalar_equal(a, b)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def roundtrip_ok(obj: Dict[str, Any], contract: Contract) -> bool:
|
|
43
|
+
"""obj -> .mini -> obj' must equal obj (and text -> obj -> text must be stable)."""
|
|
44
|
+
text = dumps(obj, contract)
|
|
45
|
+
back = parse(text, contract).to_canonical()
|
|
46
|
+
ref = {"prefix": contract.prefix, "header": dict(obj.get("header", {})),
|
|
47
|
+
contract.records_key: obj.get(contract.records_key, obj.get("records", []))}
|
|
48
|
+
ref["header"]["n"] = len(ref[contract.records_key])
|
|
49
|
+
ref["header"].setdefault("v", contract.header_keys["v"].default if "v" in contract.header_keys else 1)
|
|
50
|
+
# extension fields omitted in obj are None in back
|
|
51
|
+
for r in ref[contract.records_key]:
|
|
52
|
+
for f in contract.extensions:
|
|
53
|
+
if f.type == "mlist":
|
|
54
|
+
r.setdefault(f.json_items, None)
|
|
55
|
+
r.setdefault(f.json_selected, None)
|
|
56
|
+
else:
|
|
57
|
+
r.setdefault(f.name, None)
|
|
58
|
+
return canonical_equal(back, ref) and dumps(back, contract) == text
|
minifmt/__main__.py
ADDED
minifmt/ai/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""AI-facing helpers for .mini: selective repair and generation adapters.
|
|
2
|
+
|
|
3
|
+
>>> from minifmt.ai import repair_request, merge_repair
|
|
4
|
+
>>> req = repair_request(model_answer, contract, "es")
|
|
5
|
+
>>> if req.needed:
|
|
6
|
+
... answer = adapter.generate(req.system, req.user, max_tokens=req.max_tokens_hint * 2, temperature=0)
|
|
7
|
+
... merged = merge_repair(model_answer, answer["text"], contract, req)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .repair import (MergeResult, RepairItem, RepairRequest, extract_document, invalid_items, lenient_parse,
|
|
12
|
+
merge_repair, repair_request)
|
|
13
|
+
|
|
14
|
+
__all__ = ["repair_request", "merge_repair", "extract_document", "invalid_items", "lenient_parse",
|
|
15
|
+
"RepairRequest", "RepairItem", "MergeResult"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Generation adapters with a common interface (see :mod:`.base`)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .anthropic_adapter import AnthropicAdapter
|
|
7
|
+
from .base import Adapter, AdapterError, MissingKeyError, redact, redact_headers
|
|
8
|
+
from .deepseek_adapter import DeepSeekAdapter
|
|
9
|
+
from .groq_adapter import GroqAdapter
|
|
10
|
+
from .openai_adapter import OpenAIAdapter
|
|
11
|
+
from .simulated import DEFAULT_PROFILE, PROFILES, SimTarget, SimulatedAdapter
|
|
12
|
+
|
|
13
|
+
ADAPTERS = {"openai": OpenAIAdapter, "anthropic": AnthropicAdapter, "groq": GroqAdapter,
|
|
14
|
+
"deepseek": DeepSeekAdapter, "simulado": SimulatedAdapter, "simulated": SimulatedAdapter}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_adapter(provider: str, model: str, **options: Any) -> Adapter:
|
|
18
|
+
try:
|
|
19
|
+
cls = ADAPTERS[provider.lower()]
|
|
20
|
+
except KeyError:
|
|
21
|
+
raise AdapterError(f"unknown provider '{provider}' (known: {', '.join(sorted(ADAPTERS))})") from None
|
|
22
|
+
return cls(model, **options)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["Adapter", "AdapterError", "MissingKeyError", "OpenAIAdapter", "AnthropicAdapter", "GroqAdapter",
|
|
26
|
+
"DeepSeekAdapter",
|
|
27
|
+
"SimulatedAdapter", "SimTarget", "DEFAULT_PROFILE", "PROFILES", "get_adapter", "redact", "redact_headers"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter over plain HTTPS (no SDK dependency).
|
|
2
|
+
|
|
3
|
+
Endpoint ``POST https://api.anthropic.com/v1/messages`` with headers
|
|
4
|
+
``x-api-key`` and ``anthropic-version: 2023-06-01``. Structured output uses
|
|
5
|
+
``output_config.format = {"type": "json_schema", "schema": ...}``. Some
|
|
6
|
+
recent models reject sampling parameters; set ``send_temperature=False`` for
|
|
7
|
+
them in the experiment configuration.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .base import Adapter, AdapterError, api_key, result
|
|
14
|
+
from .http import Transport, post_json
|
|
15
|
+
|
|
16
|
+
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
|
|
17
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AnthropicAdapter(Adapter):
|
|
21
|
+
provider = "anthropic"
|
|
22
|
+
env_var = "ANTHROPIC_API_KEY"
|
|
23
|
+
|
|
24
|
+
def __init__(self, model: str, *, structured: bool = True, send_temperature: bool = True,
|
|
25
|
+
transport: Optional[Transport] = None, timeout: float = 300.0, retries: int = 3,
|
|
26
|
+
url: str = ANTHROPIC_URL, extra_body: Optional[Dict[str, Any]] = None, **options: Any):
|
|
27
|
+
super().__init__(model, **options)
|
|
28
|
+
self.supports_structured = structured
|
|
29
|
+
self.send_temperature = send_temperature
|
|
30
|
+
self.transport = transport
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self.retries = retries
|
|
33
|
+
self.url = url
|
|
34
|
+
self.extra_body = dict(extra_body or {})
|
|
35
|
+
|
|
36
|
+
def build_payload(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
37
|
+
response_format: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
38
|
+
payload: Dict[str, Any] = {
|
|
39
|
+
"model": self.model,
|
|
40
|
+
"max_tokens": int(max_tokens),
|
|
41
|
+
"system": system,
|
|
42
|
+
"messages": [{"role": "user", "content": user}],
|
|
43
|
+
}
|
|
44
|
+
if temperature is not None and self.send_temperature:
|
|
45
|
+
payload["temperature"] = temperature
|
|
46
|
+
if response_format is not None:
|
|
47
|
+
if response_format.get("type") != "json_schema":
|
|
48
|
+
raise AdapterError(f"unsupported response_format type {response_format.get('type')}")
|
|
49
|
+
payload["output_config"] = {"format": {"type": "json_schema", "schema": response_format["schema"]}}
|
|
50
|
+
for k, v in self.extra_body.items():
|
|
51
|
+
if k == "output_config" and "output_config" in payload:
|
|
52
|
+
payload["output_config"] = {**v, **payload["output_config"]}
|
|
53
|
+
else:
|
|
54
|
+
payload[k] = v
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
def generate(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
58
|
+
response_format: Optional[Dict[str, Any]] = None, seed: Optional[int] = None) -> Dict[str, Any]:
|
|
59
|
+
self._check_format(response_format) # the Messages API has no seed parameter
|
|
60
|
+
payload = self.build_payload(system, user, max_tokens=max_tokens, temperature=temperature,
|
|
61
|
+
response_format=response_format)
|
|
62
|
+
headers = {"x-api-key": api_key(self.env_var), "anthropic-version": ANTHROPIC_VERSION}
|
|
63
|
+
t0 = self._clock()
|
|
64
|
+
raw = post_json(self.url, headers, payload, timeout=self.timeout, retries=self.retries, transport=self.transport)
|
|
65
|
+
latency = (self._clock() - t0) * 1000
|
|
66
|
+
text = "".join(b.get("text", "") for b in raw.get("content", []) if b.get("type") == "text")
|
|
67
|
+
usage = raw.get("usage") or {}
|
|
68
|
+
inp = usage.get("input_tokens")
|
|
69
|
+
if inp is not None:
|
|
70
|
+
inp += int(usage.get("cache_creation_input_tokens") or 0) + int(usage.get("cache_read_input_tokens") or 0)
|
|
71
|
+
return result(text, inp, usage.get("output_tokens"), latency, raw, stop_reason=raw.get("stop_reason"),
|
|
72
|
+
model=raw.get("model", self.model), provider=self.provider)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Common interface of the generation adapters.
|
|
2
|
+
|
|
3
|
+
Every adapter exposes::
|
|
4
|
+
|
|
5
|
+
generate(system, user, *, max_tokens, temperature, response_format=None, seed=None)
|
|
6
|
+
-> {"text", "input_tokens", "output_tokens", "latency_ms", "raw",
|
|
7
|
+
"stop_reason", "model", "provider"}
|
|
8
|
+
|
|
9
|
+
``response_format`` is provider-neutral::
|
|
10
|
+
|
|
11
|
+
{"type": "json_schema", "name": "<name>", "schema": {...JSON Schema...}}
|
|
12
|
+
|
|
13
|
+
and each adapter translates it to its provider's native structured-output
|
|
14
|
+
mode. Adapters whose provider/model does not support it declare
|
|
15
|
+
``supports_structured = False``; callers must then skip the condition rather
|
|
16
|
+
than silently falling back to unconstrained generation.
|
|
17
|
+
|
|
18
|
+
API keys are read only from environment variables, are never returned,
|
|
19
|
+
logged or written anywhere; :func:`redact` masks them (and any header that
|
|
20
|
+
carries credentials) in every message that may reach a log.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
from typing import Any, Dict, Mapping, Optional
|
|
29
|
+
|
|
30
|
+
KEY_ENV_VARS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GROQ_API_KEY", "DEEPSEEK_API_KEY")
|
|
31
|
+
SENSITIVE_HEADERS = {"authorization", "x-api-key", "api-key", "proxy-authorization", "cookie"}
|
|
32
|
+
_KEY_PATTERNS = [
|
|
33
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{8,}"),
|
|
34
|
+
re.compile(r"gsk_[A-Za-z0-9]{8,}"),
|
|
35
|
+
re.compile(r"(?i)(bearer\s+)[A-Za-z0-9_\-\.=]{8,}"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AdapterError(RuntimeError):
|
|
40
|
+
"""Generation failed (network, HTTP status, malformed answer...). Message is redacted."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, *, status: Optional[int] = None, retryable: bool = False):
|
|
43
|
+
super().__init__(redact(message))
|
|
44
|
+
self.status = status
|
|
45
|
+
self.retryable = retryable
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MissingKeyError(AdapterError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def redact(text: Any) -> str:
|
|
53
|
+
"""Mask API keys and credential-looking substrings in ``text``."""
|
|
54
|
+
s = str(text)
|
|
55
|
+
for var in KEY_ENV_VARS:
|
|
56
|
+
val = os.environ.get(var)
|
|
57
|
+
if val and len(val) >= 6:
|
|
58
|
+
s = s.replace(val, "***")
|
|
59
|
+
for pat in _KEY_PATTERNS:
|
|
60
|
+
s = pat.sub(lambda m: (m.group(1) if m.groups() else "") + "***", s)
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact_headers(headers: Mapping[str, str]) -> Dict[str, str]:
|
|
65
|
+
"""Copy of ``headers`` safe to log (credential headers replaced by ***)."""
|
|
66
|
+
return {k: ("***" if k.lower() in SENSITIVE_HEADERS else redact(v)) for k, v in headers.items()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def api_key(env_var: str) -> str:
|
|
70
|
+
"""Read an API key from the environment; raises without revealing anything."""
|
|
71
|
+
val = os.environ.get(env_var, "").strip()
|
|
72
|
+
if not val:
|
|
73
|
+
raise MissingKeyError(f"environment variable {env_var} is not set")
|
|
74
|
+
return val
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def result(text: str, input_tokens: Optional[int], output_tokens: Optional[int], latency_ms: float,
|
|
78
|
+
raw: Any, *, stop_reason: Optional[str] = None, model: str = "", provider: str = "") -> Dict[str, Any]:
|
|
79
|
+
return {"text": text or "", "input_tokens": input_tokens, "output_tokens": output_tokens,
|
|
80
|
+
"latency_ms": round(float(latency_ms), 1), "raw": raw, "stop_reason": stop_reason,
|
|
81
|
+
"model": model, "provider": provider}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Adapter(ABC):
|
|
85
|
+
provider: str = "base"
|
|
86
|
+
supports_structured: bool = False
|
|
87
|
+
|
|
88
|
+
def __init__(self, model: str, **options: Any):
|
|
89
|
+
self.model = model
|
|
90
|
+
self.options = options
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def generate(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
94
|
+
response_format: Optional[Dict[str, Any]] = None, seed: Optional[int] = None) -> Dict[str, Any]:
|
|
95
|
+
"""Run one generation and return the common result dict."""
|
|
96
|
+
|
|
97
|
+
# helpers ---------------------------------------------------------------
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _clock() -> float:
|
|
100
|
+
return time.perf_counter()
|
|
101
|
+
|
|
102
|
+
def _check_format(self, response_format: Optional[Dict[str, Any]]) -> None:
|
|
103
|
+
if response_format is not None and not self.supports_structured:
|
|
104
|
+
raise AdapterError(f"{self.provider}:{self.model} does not support structured output")
|
|
105
|
+
|
|
106
|
+
def __repr__(self) -> str: # never includes credentials
|
|
107
|
+
return f"<{type(self).__name__} {self.provider}:{self.model}>"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""DeepSeek adapter over plain HTTPS (OpenAI-compatible Chat Completions endpoint).
|
|
2
|
+
|
|
3
|
+
DeepSeek publishes the OpenAI Chat Completions API, so the transport of
|
|
4
|
+
:class:`~minifmt.ai.adapters.groq_adapter.GroqAdapter` is reused; only the
|
|
5
|
+
endpoint, the key and the output-limit field differ (``max_tokens`` instead of
|
|
6
|
+
``max_completion_tokens``).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from .groq_adapter import GroqAdapter
|
|
13
|
+
|
|
14
|
+
DEEPSEEK_URL = "https://api.deepseek.com/chat/completions"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeepSeekAdapter(GroqAdapter):
|
|
18
|
+
provider = "deepseek"
|
|
19
|
+
env_var = "DEEPSEEK_API_KEY"
|
|
20
|
+
|
|
21
|
+
def __init__(self, model: str, *, url: str = DEEPSEEK_URL, **options: Any):
|
|
22
|
+
super().__init__(model, url=url, **options)
|
|
23
|
+
|
|
24
|
+
def build_payload(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
25
|
+
response_format: Optional[Dict[str, Any]], seed: Optional[int]) -> Dict[str, Any]:
|
|
26
|
+
payload = super().build_payload(system, user, max_tokens=max_tokens, temperature=temperature,
|
|
27
|
+
response_format=response_format, seed=seed)
|
|
28
|
+
payload["max_tokens"] = payload.pop("max_completion_tokens")
|
|
29
|
+
return payload
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Groq adapter over plain HTTPS (OpenAI-compatible Chat Completions endpoint).
|
|
2
|
+
|
|
3
|
+
Structured output (``response_format`` JSON schema) is available only on some
|
|
4
|
+
Groq-hosted models; declare it per model with ``structured=True``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from .base import Adapter, api_key, result
|
|
11
|
+
from .http import Transport, post_json
|
|
12
|
+
from .openai_adapter import to_openai_format
|
|
13
|
+
|
|
14
|
+
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GroqAdapter(Adapter):
|
|
18
|
+
provider = "groq"
|
|
19
|
+
env_var = "GROQ_API_KEY"
|
|
20
|
+
|
|
21
|
+
def __init__(self, model: str, *, structured: bool = False, send_temperature: bool = True,
|
|
22
|
+
transport: Optional[Transport] = None, timeout: float = 120.0, retries: int = 3,
|
|
23
|
+
url: str = GROQ_URL, extra_body: Optional[Dict[str, Any]] = None, **options: Any):
|
|
24
|
+
super().__init__(model, **options)
|
|
25
|
+
self.supports_structured = structured
|
|
26
|
+
self.send_temperature = send_temperature
|
|
27
|
+
self.transport = transport
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
self.retries = retries
|
|
30
|
+
self.url = url
|
|
31
|
+
self.extra_body = dict(extra_body or {})
|
|
32
|
+
|
|
33
|
+
def build_payload(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
34
|
+
response_format: Optional[Dict[str, Any]], seed: Optional[int]) -> Dict[str, Any]:
|
|
35
|
+
payload: Dict[str, Any] = {
|
|
36
|
+
"model": self.model,
|
|
37
|
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
|
38
|
+
"max_completion_tokens": int(max_tokens),
|
|
39
|
+
}
|
|
40
|
+
if temperature is not None and self.send_temperature:
|
|
41
|
+
payload["temperature"] = temperature
|
|
42
|
+
if seed is not None:
|
|
43
|
+
payload["seed"] = int(seed) % (2 ** 31)
|
|
44
|
+
rf = to_openai_format(response_format)
|
|
45
|
+
if rf is not None:
|
|
46
|
+
payload["response_format"] = rf
|
|
47
|
+
payload.update(self.extra_body)
|
|
48
|
+
return payload
|
|
49
|
+
|
|
50
|
+
def generate(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
51
|
+
response_format: Optional[Dict[str, Any]] = None, seed: Optional[int] = None) -> Dict[str, Any]:
|
|
52
|
+
self._check_format(response_format)
|
|
53
|
+
payload = self.build_payload(system, user, max_tokens=max_tokens, temperature=temperature,
|
|
54
|
+
response_format=response_format, seed=seed)
|
|
55
|
+
headers = {"authorization": "Bearer " + api_key(self.env_var)}
|
|
56
|
+
t0 = self._clock()
|
|
57
|
+
raw = post_json(self.url, headers, payload, timeout=self.timeout, retries=self.retries, transport=self.transport)
|
|
58
|
+
latency = (self._clock() - t0) * 1000
|
|
59
|
+
choice = (raw.get("choices") or [{}])[0]
|
|
60
|
+
text = (choice.get("message") or {}).get("content") or ""
|
|
61
|
+
usage = raw.get("usage") or {}
|
|
62
|
+
return result(text, usage.get("prompt_tokens"), usage.get("completion_tokens"), latency, raw,
|
|
63
|
+
stop_reason=choice.get("finish_reason"), model=raw.get("model", self.model), provider=self.provider)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Minimal JSON-over-HTTPS client: httpx when installed, urllib otherwise.
|
|
2
|
+
|
|
3
|
+
A ``transport`` callable can be injected for tests; it receives
|
|
4
|
+
``(url, headers, body_bytes, timeout)`` and returns ``(status, body_bytes)``.
|
|
5
|
+
Nothing here logs request headers; errors are redacted.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .base import AdapterError, redact, redact_headers
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("minifmt.ai.http")
|
|
18
|
+
|
|
19
|
+
Transport = Callable[[str, Mapping[str, str], bytes, float], Tuple[int, bytes]]
|
|
20
|
+
RETRYABLE = {408, 409, 429, 500, 502, 503, 504, 529}
|
|
21
|
+
USER_AGENT = "mini-format/1.2.0 (+https://mini-format.pmoluna.com)"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _httpx_transport(url: str, headers: Mapping[str, str], body: bytes, timeout: float) -> Tuple[int, bytes]:
|
|
25
|
+
import httpx # type: ignore
|
|
26
|
+
r = httpx.post(url, headers=dict(headers), content=body, timeout=timeout)
|
|
27
|
+
return r.status_code, r.content
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _urllib_transport(url: str, headers: Mapping[str, str], body: bytes, timeout: float) -> Tuple[int, bytes]:
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.request
|
|
33
|
+
req = urllib.request.Request(url, data=body, headers=dict(headers), method="POST")
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - fixed https endpoints
|
|
36
|
+
return resp.status, resp.read()
|
|
37
|
+
except urllib.error.HTTPError as e:
|
|
38
|
+
return e.code, e.read()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_transport() -> Transport:
|
|
42
|
+
try:
|
|
43
|
+
import httpx # noqa: F401
|
|
44
|
+
return _httpx_transport
|
|
45
|
+
except ImportError:
|
|
46
|
+
return _urllib_transport
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def post_json(url: str, headers: Mapping[str, str], payload: Dict[str, Any], *, timeout: float = 120.0,
|
|
50
|
+
retries: int = 3, transport: Optional[Transport] = None,
|
|
51
|
+
sleep: Callable[[float], None] = time.sleep) -> Dict[str, Any]:
|
|
52
|
+
"""POST ``payload`` as JSON; retries retryable statuses with jittered backoff."""
|
|
53
|
+
tp = transport or default_transport()
|
|
54
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
55
|
+
# Sin user-agent algunos proveedores (Cloudflare) rechazan la petición con 403/1010.
|
|
56
|
+
hdrs = {"content-type": "application/json", "user-agent": USER_AGENT,
|
|
57
|
+
"accept": "application/json", **headers}
|
|
58
|
+
last: Optional[AdapterError] = None
|
|
59
|
+
for attempt in range(retries + 1):
|
|
60
|
+
try:
|
|
61
|
+
status, raw = tp(url, hdrs, body, timeout)
|
|
62
|
+
except Exception as e: # noqa: BLE001 - network errors of either backend
|
|
63
|
+
last = AdapterError(f"network error calling {url}: {type(e).__name__}: {e}", retryable=True)
|
|
64
|
+
else:
|
|
65
|
+
if 200 <= status < 300:
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(raw.decode("utf-8"))
|
|
68
|
+
except ValueError as e:
|
|
69
|
+
raise AdapterError(f"non-JSON answer from {url}: {e}") from None
|
|
70
|
+
snippet = raw[:500].decode("utf-8", "replace")
|
|
71
|
+
last = AdapterError(f"HTTP {status} from {url}: {snippet}", status=status, retryable=status in RETRYABLE)
|
|
72
|
+
if not last.retryable:
|
|
73
|
+
raise last
|
|
74
|
+
if attempt < retries:
|
|
75
|
+
delay = min(30.0, (2 ** attempt) + random.random())
|
|
76
|
+
log.warning("retrying %s in %.1fs (%s; headers=%s)", url, delay, redact(last),
|
|
77
|
+
redact_headers(hdrs))
|
|
78
|
+
sleep(delay)
|
|
79
|
+
assert last is not None
|
|
80
|
+
raise last
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""OpenAI Chat Completions adapter (uses the installed ``openai`` package)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .base import Adapter, AdapterError, api_key, result
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_openai_format(response_format: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
10
|
+
"""Neutral response_format -> OpenAI ``response_format`` (strict JSON schema)."""
|
|
11
|
+
if response_format is None:
|
|
12
|
+
return None
|
|
13
|
+
if response_format.get("type") != "json_schema":
|
|
14
|
+
raise AdapterError(f"unsupported response_format type {response_format.get('type')}")
|
|
15
|
+
return {"type": "json_schema",
|
|
16
|
+
"json_schema": {"name": response_format.get("name", "salida"), "strict": True,
|
|
17
|
+
"schema": response_format["schema"]}}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenAIAdapter(Adapter):
|
|
21
|
+
provider = "openai"
|
|
22
|
+
env_var = "OPENAI_API_KEY"
|
|
23
|
+
|
|
24
|
+
def __init__(self, model: str, *, structured: bool = True, client: Any = None, send_temperature: bool = True,
|
|
25
|
+
timeout: float = 120.0, max_retries: int = 3, extra_body: Optional[Dict[str, Any]] = None, **options: Any):
|
|
26
|
+
super().__init__(model, **options)
|
|
27
|
+
self.supports_structured = structured
|
|
28
|
+
self.send_temperature = send_temperature
|
|
29
|
+
self.extra_body = dict(extra_body or {})
|
|
30
|
+
self._client = client
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
self._max_retries = max_retries
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def client(self) -> Any:
|
|
36
|
+
if self._client is None:
|
|
37
|
+
from openai import OpenAI # installed dependency of the experiment
|
|
38
|
+
self._client = OpenAI(api_key=api_key(self.env_var), timeout=self._timeout, max_retries=self._max_retries)
|
|
39
|
+
return self._client
|
|
40
|
+
|
|
41
|
+
def build_params(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
42
|
+
response_format: Optional[Dict[str, Any]], seed: Optional[int]) -> Dict[str, Any]:
|
|
43
|
+
params: Dict[str, Any] = {
|
|
44
|
+
"model": self.model,
|
|
45
|
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
|
46
|
+
"max_completion_tokens": int(max_tokens),
|
|
47
|
+
}
|
|
48
|
+
if temperature is not None and self.send_temperature:
|
|
49
|
+
params["temperature"] = temperature
|
|
50
|
+
if seed is not None:
|
|
51
|
+
params["seed"] = int(seed) % (2 ** 31)
|
|
52
|
+
rf = to_openai_format(response_format)
|
|
53
|
+
if rf is not None:
|
|
54
|
+
params["response_format"] = rf
|
|
55
|
+
params.update(self.extra_body)
|
|
56
|
+
return params
|
|
57
|
+
|
|
58
|
+
def generate(self, system: str, user: str, *, max_tokens: int, temperature: Optional[float],
|
|
59
|
+
response_format: Optional[Dict[str, Any]] = None, seed: Optional[int] = None) -> Dict[str, Any]:
|
|
60
|
+
self._check_format(response_format)
|
|
61
|
+
params = self.build_params(system, user, max_tokens=max_tokens, temperature=temperature,
|
|
62
|
+
response_format=response_format, seed=seed)
|
|
63
|
+
t0 = self._clock()
|
|
64
|
+
try:
|
|
65
|
+
resp = self.client.chat.completions.create(**params)
|
|
66
|
+
except AdapterError:
|
|
67
|
+
raise
|
|
68
|
+
except Exception as e: # noqa: BLE001 - openai.* exceptions
|
|
69
|
+
status = getattr(e, "status_code", None)
|
|
70
|
+
raise AdapterError(f"OpenAI error: {type(e).__name__}: {e}", status=status,
|
|
71
|
+
retryable=status in (408, 429, 500, 502, 503, 504)) from None
|
|
72
|
+
latency = (self._clock() - t0) * 1000
|
|
73
|
+
raw = resp.model_dump() if hasattr(resp, "model_dump") else resp
|
|
74
|
+
choice = raw["choices"][0]
|
|
75
|
+
msg = choice.get("message") or {}
|
|
76
|
+
text = msg.get("content") or msg.get("refusal") or ""
|
|
77
|
+
usage = raw.get("usage") or {}
|
|
78
|
+
return result(text, usage.get("prompt_tokens"), usage.get("completion_tokens"), latency, raw,
|
|
79
|
+
stop_reason=choice.get("finish_reason"), model=raw.get("model", self.model), provider=self.provider)
|