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 +45 -0
- everalgo/config.py +4 -0
- everalgo/llm/__init__.py +64 -0
- everalgo/llm/_filters.py +39 -0
- everalgo/llm/config.py +21 -0
- everalgo/llm/errors.py +5 -0
- everalgo/llm/factory.py +15 -0
- everalgo/llm/format.py +43 -0
- everalgo/llm/parse.py +61 -0
- everalgo/llm/protocols.py +28 -0
- everalgo/llm/providers/__init__.py +7 -0
- everalgo/llm/providers/openai_compat.py +91 -0
- everalgo/llm/py.typed +0 -0
- everalgo/llm/types.py +117 -0
- everalgo/prompts/__init__.py +15 -0
- everalgo/prompts/py.typed +0 -0
- everalgo/prompts/render.py +40 -0
- everalgo/prompts/validator.py +62 -0
- everalgo/protocols.py +4 -0
- everalgo/testing/__init__.py +36 -0
- everalgo/testing/assertions.py +60 -0
- everalgo/testing/fake_llm.py +135 -0
- everalgo/testing/py.typed +0 -0
- everalgo/types/__init__.py +80 -0
- everalgo/types/_render.py +38 -0
- everalgo/types/agent.py +97 -0
- everalgo/types/chat.py +28 -0
- everalgo/types/content.py +22 -0
- everalgo/types/conversation.py +28 -0
- everalgo/types/knowledge.py +13 -0
- everalgo/types/memories.py +65 -0
- everalgo/types/modality.py +322 -0
- everalgo/types/parsed.py +46 -0
- everalgo/types/py.typed +0 -0
- everalgo/types/rank.py +98 -0
- everalgo/types/raw.py +69 -0
- everalgo_core-0.1.0.dist-info/METADATA +152 -0
- everalgo_core-0.1.0.dist-info/RECORD +40 -0
- everalgo_core-0.1.0.dist-info/WHEEL +4 -0
- everalgo_core-0.1.0.dist-info/licenses/LICENSE +21 -0
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
everalgo/llm/__init__.py
ADDED
|
@@ -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
|
everalgo/llm/_filters.py
ADDED
|
@@ -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
everalgo/llm/factory.py
ADDED
|
@@ -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,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}")
|