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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())
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)