everalgo-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
everalgo/_tokenize.py ADDED
@@ -0,0 +1,45 @@
1
+ """Shared tokenizer utilities using OpenAI ``o200k_base`` encoding (GPT-4o / o-series).
2
+
3
+ Module-private — internal shared implementation; not part of the public ``everalgo.*`` API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import tiktoken
9
+
10
+ _ENCODING_NAME = "o200k_base"
11
+ _tokenizer_cache: tiktoken.Encoding | None = None
12
+
13
+
14
+ def _get_tokenizer() -> tiktoken.Encoding:
15
+ """Return the shared ``o200k_base`` encoding, initialising on first call."""
16
+ global _tokenizer_cache
17
+ if _tokenizer_cache is None:
18
+ _tokenizer_cache = tiktoken.get_encoding(_ENCODING_NAME)
19
+ return _tokenizer_cache
20
+
21
+
22
+ def count_tokens(text: str) -> int:
23
+ """Count tokens in ``text`` under ``o200k_base``. Empty string returns 0."""
24
+ if not text:
25
+ return 0
26
+ return len(_get_tokenizer().encode(text))
27
+
28
+
29
+ def force_split(text: str, *, max_tokens: int) -> list[str]:
30
+ """Split ``text`` into chunks of at most ``max_tokens`` tokens (no semantic awareness).
31
+
32
+ Returns ``[]`` for empty input, ``[text]`` when the whole string already fits.
33
+
34
+ Raises:
35
+ ValueError: If ``max_tokens <= 0``.
36
+ """
37
+ if max_tokens <= 0:
38
+ raise ValueError(f"max_tokens must be positive, got {max_tokens}")
39
+ if not text:
40
+ return []
41
+ tokenizer = _get_tokenizer()
42
+ token_ids = tokenizer.encode(text)
43
+ if len(token_ids) <= max_tokens:
44
+ return [text]
45
+ return [tokenizer.decode(token_ids[i : i + max_tokens]) for i in range(0, len(token_ids), max_tokens)]
everalgo/config.py ADDED
@@ -0,0 +1,4 @@
1
+ """Global configuration for the EverAlgo algorithm library.
2
+
3
+ See ``AGENTS.md`` §5 (Code Style) for the LLM injection contract.
4
+ """
@@ -0,0 +1,64 @@
1
+ """LLM facade — chat-style abstraction over OpenAI-compatible providers.
2
+
3
+ Public surface, grouped by role:
4
+
5
+ - protocol: LLMClient
6
+ - data: ChatMessage, ChatResponse, ContentPart, ImageUrlInner, ImageUrlPart, TextPart, Usage, LLMConfig
7
+ - helpers: image_url_part_from_bytes
8
+ - errors: LLMError
9
+ - factory: build_client
10
+ - format/parse: Lang, format_message_timestamp, format_natural_language_time, parse_llm_json_object
11
+
12
+ LLM binding is instance-only: every operator accepts ``llm: LLMClient`` at construction time.
13
+ There is no global default, no scoped context-var override, and no per-call resolution layer.
14
+ This matches the industry convention used by openai-python, anthropic-sdk-python, LangChain,
15
+ Instructor, and scikit-learn (5/7 dominant SDKs bind the client at construction time only).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+
22
+ from everalgo.llm._filters import SensitiveHeadersFilter
23
+ from everalgo.llm.config import LLMConfig
24
+ from everalgo.llm.errors import LLMError
25
+ from everalgo.llm.factory import build_client
26
+ from everalgo.llm.format import Lang, format_message_timestamp, format_natural_language_time
27
+ from everalgo.llm.parse import parse_llm_json_object
28
+ from everalgo.llm.protocols import LLMClient
29
+ from everalgo.llm.types import (
30
+ ChatMessage,
31
+ ChatResponse,
32
+ ContentPart,
33
+ ImageUrlInner,
34
+ ImageUrlPart,
35
+ TextPart,
36
+ Usage,
37
+ image_url_part_from_bytes,
38
+ )
39
+
40
+ # Library logging setup (ADR-013). NullHandler suppresses "No handlers" noise
41
+ # under Python's library-logging contract; SensitiveHeadersFilter redacts
42
+ # authorization-style values from `record.args` mappings, default-on.
43
+ _logger = logging.getLogger(__name__)
44
+ _logger.addHandler(logging.NullHandler())
45
+ _logger.addFilter(SensitiveHeadersFilter())
46
+
47
+ __all__ = [
48
+ "ChatMessage",
49
+ "ChatResponse",
50
+ "ContentPart",
51
+ "ImageUrlInner",
52
+ "ImageUrlPart",
53
+ "LLMClient",
54
+ "LLMConfig",
55
+ "LLMError",
56
+ "Lang",
57
+ "TextPart",
58
+ "Usage",
59
+ "build_client",
60
+ "format_message_timestamp",
61
+ "format_natural_language_time",
62
+ "image_url_part_from_bytes",
63
+ "parse_llm_json_object",
64
+ ] # fmt: skip
@@ -0,0 +1,39 @@
1
+ """Logging filters for ``everalgo.llm`` — redacts sensitive header values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from typing import Final
8
+
9
+ __all__ = ["SensitiveHeadersFilter"]
10
+
11
+
12
+ _REDACTED: Final[str] = "<redacted>"
13
+
14
+ # Matches header / parameter names that conventionally carry credentials.
15
+ # Case-insensitive; covers Authorization, Api-Key / api_key, X-Api-Key,
16
+ # Bearer tokens. Keep narrow — false positives leak data; false negatives
17
+ # leak credentials.
18
+ _SENSITIVE_KEY_PATTERN: Final[re.Pattern[str]] = re.compile(
19
+ r"(authorization|api[-_]?key|x-api-key|bearer)",
20
+ re.IGNORECASE,
21
+ )
22
+
23
+
24
+ class SensitiveHeadersFilter(logging.Filter):
25
+ """Redact sensitive header values in ``LogRecord.args`` mappings.
26
+
27
+ Default-attached to ``everalgo.llm``; ``filter()`` always returns ``True`` — it mutates payload, never
28
+ drops records. Only mapping-shaped ``record.args`` are scanned; tuple / ``None`` args pass through.
29
+ Does not scan request/response bodies (body PII is out of scope for a header filter).
30
+ """
31
+
32
+ def filter(self, record: logging.LogRecord) -> bool:
33
+ """Redact sensitive entries from ``record.args`` in place; always returns ``True``."""
34
+ if isinstance(record.args, dict):
35
+ record.args = {
36
+ key: (_REDACTED if _SENSITIVE_KEY_PATTERN.search(str(key)) else value)
37
+ for key, value in record.args.items()
38
+ }
39
+ return True
everalgo/llm/config.py ADDED
@@ -0,0 +1,21 @@
1
+ """LLM client configuration."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field, SecretStr
6
+
7
+
8
+ class LLMConfig(BaseModel):
9
+ """OpenAI-compatible LLM client configuration.
10
+
11
+ ``api_key`` uses ``SecretStr`` so repr / serialization never leaks the raw key; call
12
+ ``config.api_key.get_secret_value()`` explicitly when passing it to an SDK.
13
+ """
14
+
15
+ model: str
16
+ api_key: SecretStr
17
+ base_url: str
18
+ temperature: float = 0.0
19
+ max_tokens: int | None = None
20
+ timeout: float = 60.0
21
+ extra: dict[str, Any] = Field(default_factory=dict)
everalgo/llm/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """LLM-layer error types — minimal single-base set."""
2
+
3
+
4
+ class LLMError(Exception):
5
+ """Raised on any LLM call failure. Inspect ``__cause__`` (PEP 3134) for the original SDK exception."""
@@ -0,0 +1,15 @@
1
+ """Factory for building an LLM client from configuration."""
2
+
3
+ from everalgo.llm.config import LLMConfig
4
+ from everalgo.llm.protocols import LLMClient
5
+
6
+
7
+ def build_client(config: LLMConfig) -> LLMClient:
8
+ """Build an OpenAI-compatible LLM client.
9
+
10
+ ``OpenAICompatClient`` is imported lazily so that ``import everalgo.llm`` remains cheap for callers
11
+ that only need the Protocol / Config / Error types. Do not hoist this to a top-level import.
12
+ """
13
+ from everalgo.llm.providers.openai_compat import OpenAICompatClient
14
+
15
+ return OpenAICompatClient(config)
everalgo/llm/format.py ADDED
@@ -0,0 +1,43 @@
1
+ """Time-formatting helpers for LLM prompt rendering.
2
+
3
+ Two flavours:
4
+ - ``format_message_timestamp`` — ISO 8601 UTC anchor for inline conversation-line
5
+ prefixes (e.g. ``[2023-11-14T22:13:20Z] Alice: ...``). Language-agnostic.
6
+ - ``format_natural_language_time`` — human-readable label for LLM time-of-day
7
+ reasoning (e.g. ``Conversation start time:`` / ``TIME:``). Supports EN + ZH;
8
+ callers pick via ``lang`` (default ``"en"`` matches the current EverAlgo
9
+ default-EN prompt policy).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import UTC, datetime
15
+ from typing import Literal
16
+
17
+ __all__ = ["Lang", "format_message_timestamp", "format_natural_language_time"]
18
+
19
+ Lang = Literal["en", "zh"]
20
+
21
+
22
+ def format_message_timestamp(timestamp_ms: int) -> str:
23
+ """ISO 8601 UTC for conversation-line prefixes (e.g. ``2023-11-14T22:13:20Z``).
24
+
25
+ Language-agnostic. Used wherever an LLM prompt needs a machine-readable
26
+ time anchor in front of a message body.
27
+ """
28
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
29
+
30
+
31
+ def format_natural_language_time(timestamp_ms: int, *, lang: Lang = "en") -> str:
32
+ """Human-readable timestamp for LLM time-of-day reasoning labels.
33
+
34
+ EN: ``November 14, 2023 (Tuesday) at 10:13 PM UTC``
35
+ ZH: ``2023 年 11 月 14 日 (星期二) 下午 10:13 UTC`` (uses CJK parentheses in the actual output)
36
+ """
37
+ dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
38
+ if lang == "zh":
39
+ weekday = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"][dt.weekday()]
40
+ hour_12 = dt.hour % 12 or 12
41
+ ampm = "下午" if dt.hour >= 12 else "上午"
42
+ return f"{dt.year} 年 {dt.month} 月 {dt.day} 日({weekday}){ampm} {hour_12}:{dt.minute:02d} UTC" # noqa: RUF001
43
+ return dt.strftime("%B %-d, %Y (%A) at %-I:%M %p UTC")
everalgo/llm/parse.py ADDED
@@ -0,0 +1,61 @@
1
+ """Robust LLM JSON-object parsing.
2
+
3
+ Used wherever an LLM returns a string that should decode to a JSON object. Handles the common
4
+ LLM output quirks (markdown fence wrapping, prose preamble around the JSON, etc.) on top of
5
+ OpenAI JSON mode's happy path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from typing import Any, cast
13
+
14
+ __all__ = ["parse_llm_json_object"]
15
+
16
+
17
+ def parse_llm_json_object(raw: str) -> dict[str, Any]:
18
+ """Parse ``raw`` as a JSON object via three strategies in order.
19
+
20
+ Tries each strategy until one succeeds:
21
+
22
+ 1. `` ```json ... ``` `` fenced block
23
+ 2. Direct ``json.loads`` on the full string (the JSON-mode happy path)
24
+ 3. Outermost ``{ ... }`` substring (rescues prose-wrapped JSON)
25
+
26
+ Args:
27
+ raw: Raw string returned by the LLM.
28
+
29
+ Returns:
30
+ The parsed dict.
31
+
32
+ Raises:
33
+ ValueError: If all three strategies fail or the result is not a JSON object.
34
+ """
35
+ fence_match = re.search(r"```json\s*(.*?)\s*```", raw, re.DOTALL)
36
+ if fence_match:
37
+ try:
38
+ parsed = json.loads(fence_match.group(1))
39
+ if isinstance(parsed, dict):
40
+ return cast("dict[str, Any]", parsed)
41
+ except (json.JSONDecodeError, ValueError):
42
+ pass
43
+
44
+ try:
45
+ parsed = json.loads(raw.strip())
46
+ if isinstance(parsed, dict):
47
+ return cast("dict[str, Any]", parsed)
48
+ except (json.JSONDecodeError, ValueError):
49
+ pass
50
+
51
+ start = raw.find("{")
52
+ end = raw.rfind("}")
53
+ if start != -1 and end > start:
54
+ try:
55
+ parsed = json.loads(raw[start : end + 1])
56
+ if isinstance(parsed, dict):
57
+ return cast("dict[str, Any]", parsed)
58
+ except (json.JSONDecodeError, ValueError):
59
+ pass
60
+
61
+ raise ValueError("Failed to parse LLM response as a JSON object")
@@ -0,0 +1,28 @@
1
+ """LLM client Protocol — the structural contract every provider satisfies."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+ from everalgo.llm.types import ChatMessage, ChatResponse
7
+
8
+
9
+ @runtime_checkable
10
+ class LLMClient(Protocol):
11
+ """Async LLM client structural contract (PEP 544). Structural conformance suffices — no subclassing needed."""
12
+
13
+ async def chat(
14
+ self,
15
+ messages: list[ChatMessage],
16
+ *,
17
+ model: str | None = None,
18
+ temperature: float | None = None,
19
+ max_tokens: int | None = None,
20
+ response_format: Mapping[str, Any] | None = None,
21
+ **extra: Any,
22
+ ) -> ChatResponse:
23
+ """Send a chat request and return the structured response.
24
+
25
+ Raises:
26
+ LLMError: On any provider-side failure; original SDK exception attached as ``__cause__``.
27
+ """
28
+ ...
@@ -0,0 +1,7 @@
1
+ """Concrete LLM provider adapters (openai_compat / anthropic / bedrock / vllm).
2
+
3
+ Providers live *inside* the ``llm`` package — not as a top-level distribution —
4
+ to mirror litellm / instructor / dspy / llama-index conventions.
5
+ """
6
+
7
+ __all__: list[str] = []
@@ -0,0 +1,91 @@
1
+ """OpenAI-compatible provider — wraps openai.AsyncOpenAI."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, Literal
5
+
6
+ import openai
7
+
8
+ from everalgo.llm.config import LLMConfig
9
+ from everalgo.llm.errors import LLMError
10
+ from everalgo.llm.types import ChatMessage, ChatResponse, Usage
11
+
12
+
13
+ class OpenAICompatClient:
14
+ """Thin async wrapper over ``openai.AsyncOpenAI``.
15
+
16
+ Converts between EverAlgo types and the openai SDK shapes. No retry, rate-limit, or key-rotation logic —
17
+ those are caller concerns.
18
+ """
19
+
20
+ def __init__(self, config: LLMConfig) -> None:
21
+ self._config = config
22
+ self._client = openai.AsyncOpenAI(
23
+ api_key=config.api_key.get_secret_value(),
24
+ base_url=config.base_url,
25
+ timeout=config.timeout,
26
+ )
27
+
28
+ async def chat(
29
+ self,
30
+ messages: list[ChatMessage],
31
+ *,
32
+ model: str | None = None,
33
+ temperature: float | None = None,
34
+ max_tokens: int | None = None,
35
+ response_format: Mapping[str, Any] | None = None,
36
+ **extra: Any,
37
+ ) -> ChatResponse:
38
+ """Implement ``LLMClient.chat`` — see protocols.py for contract."""
39
+ request_kwargs: dict[str, Any] = {
40
+ "model": model or self._config.model,
41
+ "messages": [m.model_dump() for m in messages],
42
+ "temperature": (temperature if temperature is not None else self._config.temperature),
43
+ }
44
+ max_tokens_val = max_tokens if max_tokens is not None else self._config.max_tokens
45
+ if max_tokens_val is not None:
46
+ request_kwargs["max_tokens"] = max_tokens_val
47
+ if response_format is not None:
48
+ request_kwargs["response_format"] = dict(response_format)
49
+ request_kwargs.update(self._config.extra)
50
+ request_kwargs.update(extra)
51
+
52
+ try:
53
+ completion = await self._client.chat.completions.create(**request_kwargs)
54
+ except openai.OpenAIError as exc:
55
+ raise LLMError(str(exc)) from exc
56
+
57
+ # Some OpenAI-compatible upstreams (notably OpenRouter when the upstream
58
+ # model rejects the payload) return a 200 with ``choices=None`` or an
59
+ # empty list rather than a structured HTTP error. Surface as LLMError
60
+ # rather than crashing with ``NoneType`` subscript.
61
+ if not completion.choices:
62
+ raise LLMError(
63
+ f"upstream returned no choices (model={completion.model!r}); "
64
+ f"likely an upstream rejection or content filter"
65
+ )
66
+ choice = completion.choices[0]
67
+ usage: Usage | None = None
68
+ if completion.usage is not None:
69
+ usage = Usage(
70
+ prompt_tokens=completion.usage.prompt_tokens,
71
+ completion_tokens=completion.usage.completion_tokens,
72
+ )
73
+
74
+ finish_reason = _normalise_finish_reason(choice.finish_reason)
75
+
76
+ return ChatResponse(
77
+ content=choice.message.content or "",
78
+ model=completion.model,
79
+ usage=usage,
80
+ finish_reason=finish_reason,
81
+ raw=None,
82
+ )
83
+
84
+
85
+ def _normalise_finish_reason(
86
+ value: str | None,
87
+ ) -> Literal["stop", "length", "content_filter"] | None:
88
+ """Collapse provider finish reasons to EverAlgo's 3-value Literal; unknown values map to ``None``."""
89
+ if value in ("stop", "length", "content_filter"):
90
+ return value # type: ignore[return-value]
91
+ return None
everalgo/llm/py.typed ADDED
File without changes
everalgo/llm/types.py ADDED
@@ -0,0 +1,117 @@
1
+ """LLM wire types — chat-style messages, response, token usage.
2
+
3
+ These are the on-the-wire data contracts a caller sees when invoking ``LLMClient.chat``. They mirror the
4
+ OpenAI Chat Completions API closely so the openai_compat provider can pass through values with minimal
5
+ translation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from typing import Annotated, Any, Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class TextPart(BaseModel):
17
+ """Text segment inside a multimodal ``content`` array.
18
+
19
+ Serialises to ``{"type": "text", "text": "..."}`` — the OpenAI / OpenRouter wire form.
20
+ """
21
+
22
+ type: Literal["text"] = "text"
23
+ text: str
24
+
25
+ model_config = ConfigDict(extra="ignore")
26
+
27
+
28
+ class ImageUrlInner(BaseModel):
29
+ """Inner object of an ``image_url`` content part."""
30
+
31
+ url: str
32
+ detail: Literal["low", "high", "auto"] | None = None
33
+
34
+ model_config = ConfigDict(extra="ignore")
35
+
36
+
37
+ class ImageUrlPart(BaseModel):
38
+ """File-by-data-URI content part.
39
+
40
+ Serialises to ``{"type": "image_url", "image_url": {"url": "...", "detail": ...}}``. Named "image_url" for
41
+ OpenAI / OpenRouter wire compatibility, but the data URI may carry any MIME the upstream model accepts
42
+ (``application/pdf``, ``audio/wav``, ``image/png`` ...); OpenRouter + Gemini routes all of them through
43
+ this single part type.
44
+ """
45
+
46
+ type: Literal["image_url"] = "image_url"
47
+ image_url: ImageUrlInner
48
+
49
+ model_config = ConfigDict(extra="ignore")
50
+
51
+
52
+ ContentPart = Annotated[TextPart | ImageUrlPart, Field(discriminator="type")]
53
+ """Discriminated union of content parts. Add new arms as providers gain native types."""
54
+
55
+
56
+ def image_url_part_from_bytes(data: bytes, mime: str) -> ImageUrlPart:
57
+ """Build an ``ImageUrlPart`` from raw bytes by base64-encoding into a data URI.
58
+
59
+ Parameters
60
+ ----------
61
+ data : bytes
62
+ Raw file payload (image / pdf / audio bytes).
63
+ mime : str
64
+ MIME type, e.g. ``image/png`` / ``application/pdf`` / ``audio/wav``.
65
+ """
66
+ b64 = base64.b64encode(data).decode("ascii")
67
+ return ImageUrlPart(image_url=ImageUrlInner(url=f"data:{mime};base64,{b64}"))
68
+
69
+
70
+ class ChatMessage(BaseModel):
71
+ """Single chat-style message turn.
72
+
73
+ ``content`` accepts either a plain ``str`` (text-only turn, the common case) or a ``list[ContentPart]``
74
+ for multimodal turns (text + image / pdf / audio data URIs). When serialised the OpenAI / OpenRouter wire
75
+ form falls out automatically:
76
+
77
+ - ``{"role": "user", "content": "hi"}`` — text
78
+ - ``{"role": "user", "content": [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "data:..."}}]}`` — multimodal
79
+
80
+ The ``role`` set is intentionally narrow (3 values). ``tool`` is out of EPISODE scope; adding it later is
81
+ a SemVer minor bump (extending a Literal is a backward-compatible structural widening).
82
+ """
83
+
84
+ role: Literal["system", "user", "assistant"]
85
+ content: str | list[ContentPart]
86
+
87
+ model_config = ConfigDict(extra="ignore")
88
+
89
+
90
+ class Usage(BaseModel):
91
+ """Token usage from a single LLM call.
92
+
93
+ Both fields are ``int | None`` because some self-hosted / OpenAI-compatible backends do not return
94
+ ``usage`` in the response. ``None`` semantically distinguishes "missing data" from "zero tokens used".
95
+ """
96
+
97
+ prompt_tokens: int | None = None
98
+ completion_tokens: int | None = None
99
+
100
+
101
+ class ChatResponse(BaseModel):
102
+ """Structured response from a single LLM chat call."""
103
+
104
+ content: str
105
+ model: str
106
+ usage: Usage | None = None
107
+ finish_reason: Literal["stop", "length", "content_filter"] | None = None
108
+ raw: dict[str, Any] | None = Field(
109
+ default=None,
110
+ description=(
111
+ "Provider-specific original response payload. Populated only when "
112
+ "the provider implementation explicitly opts in (e.g. debug mode). "
113
+ "Production callers should rely on the structured "
114
+ "``content`` / ``usage`` / ``finish_reason`` fields and not "
115
+ "depend on ``raw`` being non-None."
116
+ ),
117
+ )
@@ -0,0 +1,15 @@
1
+ """Prompt utilities shared across the library.
2
+
3
+ Per AGENTS.md §5 (Code Style): concrete prompt strings live next to their algorithm in each subpackage at
4
+ ``<subpkg>/prompts/{en,zh}/<name>.py`` as module-level constants — not in this directory. This package only
5
+ ships the cross-cutting helpers (rendering, validation) every operator reuses.
6
+ """
7
+
8
+ import logging
9
+
10
+ from everalgo.prompts.render import render_prompt
11
+
12
+ __all__ = ["render_prompt"]
13
+
14
+ # Library logging setup (ADR-013): NullHandler on each subpackage logger.
15
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
File without changes
@@ -0,0 +1,40 @@
1
+ """Render a prompt template with caller-provided fields, falling back to a default.
2
+
3
+ Uses ``str.replace`` to substitute ``{key}`` placeholders so the template body can contain
4
+ literal ``{`` / ``}`` (e.g. JSON examples) without escaping.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def render_prompt(default: str, prompt: str | None, /, **fields: Any) -> str:
13
+ """Render ``prompt`` with ``fields``; if ``prompt`` is None, use ``default``.
14
+
15
+ Substitutes each ``{key}`` placeholder in the template with ``str(value)``. Template bodies
16
+ may freely contain literal braces (e.g. JSON examples) — only the exact ``{key}`` substring
17
+ is replaced.
18
+
19
+ Args:
20
+ default: Module-level prompt constant shipped with the operator.
21
+ prompt: Caller override; if ``None``, falls back to ``default``.
22
+ **fields: Each key=value renders ``{key}`` → ``str(value)``. Missing placeholders are left
23
+ in the output verbatim (no KeyError) so a typo'd template degrades gracefully rather
24
+ than crashing the LLM call.
25
+
26
+ Returns:
27
+ Rendered prompt string.
28
+
29
+ Examples:
30
+ >>> render_prompt("Hello, {name}!", None, name="world")
31
+ 'Hello, world!'
32
+ >>> render_prompt("Hello, {name}!", "Hi {name}", name="world")
33
+ 'Hi world'
34
+ >>> render_prompt('Output JSON like {"key": "value"} for {name}', None, name="Alice")
35
+ 'Output JSON like {"key": "value"} for Alice'
36
+ """
37
+ template = prompt or default
38
+ for key, value in fields.items():
39
+ template = template.replace("{" + key + "}", str(value))
40
+ return template
@@ -0,0 +1,62 @@
1
+ """Prompt validators — fail-fast checks for prompt templates.
2
+
3
+ Designed to be called at module import time after a prompt constant is defined, so that template typos are
4
+ caught before any LLM call.
5
+ """
6
+
7
+ import string
8
+ from collections.abc import Callable, Iterable
9
+
10
+
11
+ def check_placeholders(prompt: str, *, required: Iterable[str]) -> None:
12
+ """Assert that ``prompt`` contains every placeholder in ``required``; attribute/index access collapses to root.
13
+
14
+ Raises:
15
+ ValueError: If any required placeholder is missing; error message also lists any extra placeholders.
16
+ """
17
+ found: set[str] = set()
18
+ for _, field_name, _, _ in string.Formatter().parse(prompt):
19
+ if not field_name:
20
+ continue
21
+ # Reduce ``user.name`` / ``items[0]`` to root identifier.
22
+ root = field_name.split(".", 1)[0].split("[", 1)[0]
23
+ if root:
24
+ found.add(root)
25
+
26
+ required_set = set(required)
27
+ missing = required_set - found
28
+ if not missing:
29
+ return
30
+
31
+ extras = found - required_set
32
+ msg = f"Missing required placeholders: {sorted(missing)}"
33
+ if extras:
34
+ msg += f" (extra placeholders present: {sorted(extras)})"
35
+ raise ValueError(msg)
36
+
37
+
38
+ def _default_token_estimator(text: str) -> int:
39
+ """Return a coarse over-estimate (~4 chars/token); intentionally conservative so CJK text never slips through."""
40
+ return max(1, len(text) // 4 + 1)
41
+
42
+
43
+ def check_length(
44
+ prompt: str,
45
+ *,
46
+ max_tokens: int,
47
+ tokenizer: Callable[[str], int] | None = None,
48
+ ) -> None:
49
+ """Assert that ``prompt`` is at most ``max_tokens`` tokens long.
50
+
51
+ Args:
52
+ prompt: Rendered prompt (post-format).
53
+ max_tokens: Hard ceiling — typically context window minus response reserve.
54
+ tokenizer: Token counter; ``None`` falls back to the conservative character-based heuristic.
55
+
56
+ Raises:
57
+ ValueError: If the token count exceeds ``max_tokens``.
58
+ """
59
+ counter = tokenizer if tokenizer is not None else _default_token_estimator
60
+ actual = counter(prompt)
61
+ if actual > max_tokens:
62
+ raise ValueError(f"Prompt length {actual} tokens exceeds max_tokens={max_tokens}")