python-corekit 0.3.0__py3-none-any.whl → 0.4.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.
corekit/llm/events.py ADDED
@@ -0,0 +1,96 @@
1
+ """
2
+ Transport-agnostic stream events from a tool-aware loop.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from corekit.llm.enum import StopReason, StreamEventType
10
+ from corekit.llm.tools.base import ToolCall, ToolResult
11
+
12
+ __all__ = [
13
+ "DoneEvent",
14
+ "StopEvent",
15
+ "StreamEvent",
16
+ "TextEvent",
17
+ "ThinkingEvent",
18
+ "ToolCallEvent",
19
+ "ToolResultEvent",
20
+ "UIComponentEvent",
21
+ "UsageEvent",
22
+ ]
23
+
24
+
25
+ class StreamEvent(BaseModel):
26
+ """
27
+ Base event yielded by the tool-aware streaming loop.
28
+
29
+ Subclasses fix ``kind``; ``payload()`` is what an SSE ``data:`` line would
30
+ carry (``kind`` is omitted — it belongs on the event name).
31
+ """
32
+
33
+ kind: StreamEventType
34
+
35
+ def payload(self) -> dict[str, Any]:
36
+ """
37
+ JSON-serializable payload without the ``kind`` field.
38
+ """
39
+ return self.model_dump(exclude={"kind"})
40
+
41
+
42
+ class TextEvent(StreamEvent):
43
+ kind: StreamEventType = StreamEventType.TEXT
44
+ content: str
45
+
46
+
47
+ class ThinkingEvent(StreamEvent):
48
+ kind: StreamEventType = StreamEventType.THINKING
49
+ content: str
50
+
51
+
52
+ class ToolCallEvent(StreamEvent):
53
+ kind: StreamEventType = StreamEventType.TOOL_CALL
54
+ tool_call: ToolCall
55
+
56
+ def payload(self) -> dict[str, Any]:
57
+ return self.tool_call.model_dump()
58
+
59
+
60
+ class ToolResultEvent(StreamEvent):
61
+ kind: StreamEventType = StreamEventType.TOOL_RESULT
62
+ tool_result: ToolResult
63
+
64
+ def payload(self) -> dict[str, Any]:
65
+ return self.tool_result.model_dump()
66
+
67
+
68
+ class UIComponentEvent(StreamEvent):
69
+ """
70
+ Halt payload for a UI tool — shape is consumer-defined.
71
+ """
72
+
73
+ kind: StreamEventType = StreamEventType.UI_COMPONENT
74
+ tool_call_id: str
75
+ name: str
76
+ component: dict[str, Any]
77
+
78
+
79
+ class UsageEvent(StreamEvent):
80
+ kind: StreamEventType = StreamEventType.USAGE
81
+ total_tokens: int | None = None
82
+ prompt_tokens: int | None = None
83
+ completion_tokens: int | None = None
84
+
85
+
86
+ class StopEvent(StreamEvent):
87
+ kind: StreamEventType = StreamEventType.STOP
88
+ reason: StopReason
89
+
90
+
91
+ class DoneEvent(StreamEvent):
92
+ """
93
+ Normal completion of a streamed turn with no further tool work.
94
+ """
95
+
96
+ kind: StreamEventType = StreamEventType.DONE
@@ -0,0 +1,173 @@
1
+ """
2
+ OpenAI-shaped chat messages — base plus role-specific subclasses.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from corekit.llm.enum import ContentPartType, Role, ToolCallType, WireField
13
+ from corekit.llm.tools.base import ToolCall
14
+
15
+ __all__ = [
16
+ "AssistantMessage",
17
+ "ChatTurn",
18
+ "Message",
19
+ "SystemMessage",
20
+ "ToolMessage",
21
+ "UserMessage",
22
+ ]
23
+
24
+ from corekit.utils import safe_list
25
+
26
+
27
+ class Message(BaseModel):
28
+ """
29
+ Shared chat-turn base. Prefer a role-specific subclass for construction.
30
+ """
31
+
32
+ role: Role
33
+ content: str
34
+
35
+ def format(self, *, internal: bool = False) -> dict[str, Any]:
36
+ """
37
+ Render as an OpenAI chat-message dict.
38
+
39
+ ``internal`` is reserved for subclasses that add provider-specific
40
+ fields (e.g. assistant ``reasoning_content``).
41
+ """
42
+ del internal # base turns ignore it
43
+ return {WireField.ROLE: self.role.value, WireField.CONTENT: self.content}
44
+
45
+ @classmethod
46
+ def as_wire(
47
+ cls,
48
+ messages: Sequence[ChatTurn],
49
+ *,
50
+ internal: bool = False,
51
+ ) -> list[dict[str, Any]]:
52
+ """
53
+ Normalize ``Message`` instances and raw mappings to OpenAI wire dicts.
54
+ """
55
+ wire: list[dict[str, Any]] = []
56
+ for message in messages:
57
+ if isinstance(message, Message):
58
+ wire.append(message.format(internal=internal))
59
+ else:
60
+ wire.append(dict(message))
61
+ return wire
62
+
63
+ def prettify(self) -> str:
64
+ """
65
+ Compact debug string for logs and hashes.
66
+ """
67
+ return f"[{self.role}]: {self.content}"
68
+
69
+
70
+ class SystemMessage(Message):
71
+ """
72
+ System instruction turn.
73
+ """
74
+
75
+ role: Role = Field(default=Role.SYSTEM, frozen=True)
76
+
77
+
78
+ class UserMessage(Message):
79
+ """
80
+ User turn, optionally multimodal via ``images``.
81
+ """
82
+
83
+ role: Role = Field(default=Role.USER, frozen=True)
84
+ images: list[str] | None = None # base64 data URIs or URLs
85
+
86
+ def format(self, *, internal: bool = False) -> dict[str, Any]:
87
+ del internal
88
+ message: dict[str, Any] = {WireField.ROLE: self.role.value}
89
+ if self.images:
90
+ message[WireField.CONTENT] = self._image_content_parts()
91
+ else:
92
+ message[WireField.CONTENT] = self.content
93
+ return message
94
+
95
+ def prettify(self) -> str:
96
+ image_note = f" [+{len(self.images)} image(s)]" if self.images else ""
97
+ return f"[{self.role}]: {self.content}{image_note}"
98
+
99
+ def _image_content_parts(self) -> list[dict[str, Any]]:
100
+ parts: list[dict[str, Any]] = [
101
+ {
102
+ WireField.TYPE: ContentPartType.IMAGE_URL,
103
+ WireField.IMAGE_URL: {WireField.URL: img},
104
+ }
105
+ for img in safe_list(self.images)
106
+ ]
107
+ parts.append({WireField.TYPE: ContentPartType.TEXT, WireField.TEXT: self.content})
108
+ return parts
109
+
110
+
111
+ class AssistantMessage(Message):
112
+ """
113
+ Assistant turn — optional prior reasoning and/or tool calls.
114
+ """
115
+
116
+ role: Role = Field(default=Role.ASSISTANT, frozen=True)
117
+ reasoning: str | None = None # prior thinking (internal endpoints)
118
+ tool_calls: list[ToolCall] | None = None
119
+
120
+ def format(self, *, internal: bool = False) -> dict[str, Any]:
121
+ """
122
+ When ``internal`` is True and reasoning is set, include
123
+ ``reasoning_content`` for llama.cpp-style servers. Other providers
124
+ reject that field.
125
+ """
126
+ message: dict[str, Any] = {
127
+ WireField.ROLE: self.role.value,
128
+ WireField.CONTENT: self.content,
129
+ }
130
+ if self.tool_calls:
131
+ message[WireField.TOOL_CALLS] = [_tool_call_wire(call) for call in self.tool_calls]
132
+ if internal and self.reasoning:
133
+ message[WireField.REASONING_CONTENT] = self.reasoning
134
+ return message
135
+
136
+ @classmethod
137
+ def with_tools(cls, content: str, tool_calls: list[ToolCall]) -> AssistantMessage:
138
+ return cls(content=content, tool_calls=tool_calls)
139
+
140
+
141
+ class ToolMessage(Message):
142
+ """
143
+ Tool-result turn; ``tool_call_id`` links back to the assistant call.
144
+ """
145
+
146
+ role: Role = Field(default=Role.TOOL, frozen=True)
147
+ tool_call_id: str
148
+
149
+ def format(self, *, internal: bool = False) -> dict[str, Any]:
150
+ del internal
151
+ return {
152
+ WireField.ROLE: self.role.value,
153
+ WireField.CONTENT: self.content,
154
+ WireField.TOOL_CALL_ID: self.tool_call_id,
155
+ }
156
+
157
+ @classmethod
158
+ def from_result(cls, tool_call_id: str, content: str) -> ToolMessage:
159
+ return cls(content=content, tool_call_id=tool_call_id)
160
+
161
+
162
+ def _tool_call_wire(call: ToolCall) -> dict[str, Any]:
163
+ return {
164
+ WireField.ID: call.id,
165
+ WireField.TYPE: ToolCallType.FUNCTION,
166
+ WireField.FUNCTION: {
167
+ WireField.NAME: call.name,
168
+ WireField.ARGUMENTS: call.arguments_json(),
169
+ },
170
+ }
171
+
172
+
173
+ ChatTurn = Message | Mapping[str, Any]
@@ -0,0 +1,19 @@
1
+ """
2
+ Mountable prompt loader for versioned ``.txt`` trees.
3
+ """
4
+
5
+ from corekit.llm.prompts.enum import PromptFileRole, PromptKind, ThinkingLevel
6
+ from corekit.llm.prompts.exceptions import PromptError, PromptNotFoundError
7
+ from corekit.llm.prompts.loader import DEFAULT_PROMPT_VERSION, PromptLoader
8
+ from corekit.llm.prompts.template import PromptTemplate
9
+
10
+ __all__ = [
11
+ "DEFAULT_PROMPT_VERSION",
12
+ "PromptError",
13
+ "PromptFileRole",
14
+ "PromptKind",
15
+ "PromptLoader",
16
+ "PromptNotFoundError",
17
+ "PromptTemplate",
18
+ "ThinkingLevel",
19
+ ]
@@ -0,0 +1,54 @@
1
+ """
2
+ Prompt-tree enumerations (category, file role, thinking budget).
3
+ """
4
+
5
+ from corekit.schemas import StringEnum
6
+
7
+ __all__ = ["PromptFileRole", "PromptKind", "ThinkingLevel"]
8
+
9
+
10
+ class PromptKind(StringEnum):
11
+ """
12
+ Top-level folders under a mounted prompt root.
13
+
14
+ Custom categories are fine — pass a plain string to ``PromptLoader`` instead.
15
+ """
16
+
17
+ CLASSIFICATION = "classification"
18
+ COMMON = "common"
19
+ CONVERSATION = "conversation"
20
+ DESCRIPTION = "description"
21
+ DOCUMENT = "document"
22
+ EVALUATION = "evaluation"
23
+ EXTRACTION = "extraction"
24
+ MEMORY = "memory"
25
+ RERANKING = "reranking"
26
+ SUMMARIZATION = "summarization"
27
+ TRANSFORMATION = "transformation"
28
+
29
+
30
+ class PromptFileRole(StringEnum):
31
+ """
32
+ Filename role prefix: ``{role}-v{version}.txt``.
33
+ """
34
+
35
+ SYSTEM = "system"
36
+ USER = "user"
37
+ FRAGMENT = "fragment"
38
+
39
+
40
+ class ThinkingLevel(StringEnum):
41
+ """
42
+ Which ``common/thinking_*`` fragment to append to the system prompt.
43
+ """
44
+
45
+ NONE = "none"
46
+ LOW = "low"
47
+ DEFAULT = "default"
48
+ HIGH = "high"
49
+
50
+ def is_none(self) -> bool:
51
+ return self == ThinkingLevel.NONE
52
+
53
+ def fragment_name(self) -> str:
54
+ return f"thinking_{self.value}"
@@ -0,0 +1,22 @@
1
+ """
2
+ Prompt loader exceptions.
3
+ """
4
+
5
+ from corekit.exceptions import InternalCoreException, Retryability
6
+
7
+ __all__ = ["PromptError", "PromptNotFoundError"]
8
+
9
+
10
+ class PromptError(InternalCoreException):
11
+ """
12
+ Prompt loader configuration or resolution failure.
13
+ """
14
+
15
+ def __init__(self, message: str, *, error: str | None = None) -> None:
16
+ super().__init__(message, retryable=Retryability.NON_RETRYABLE, error=error)
17
+
18
+
19
+ class PromptNotFoundError(PromptError):
20
+ """
21
+ Expected prompt file is missing under the mounted root.
22
+ """
@@ -0,0 +1,139 @@
1
+ """
2
+ Filesystem prompt loader — mount any compatible versioned ``.txt`` tree.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from corekit.llm.prompts.enum import PromptFileRole, PromptKind, ThinkingLevel
11
+ from corekit.llm.prompts.exceptions import PromptError, PromptNotFoundError
12
+ from corekit.llm.prompts.template import PromptTemplate
13
+ from corekit.observability import Benchmarkable
14
+ from corekit.schemas import SemanticVersion
15
+
16
+ __all__ = ["PromptLoader"]
17
+
18
+ # ``{COMMON:confidence_calibration_scale}`` → fragment body (one pass).
19
+ _COMMON_INCLUDE = re.compile(r"\{COMMON:([a-z][a-z0-9_]*)\}")
20
+
21
+ DEFAULT_PROMPT_VERSION = SemanticVersion(major=1, minor=0, patch=0)
22
+
23
+ VersionLike = SemanticVersion | str | int
24
+ KindLike = PromptKind | str
25
+
26
+
27
+ class PromptLoader(Benchmarkable):
28
+ """
29
+ Read versioned prompt files from a mounted directory tree.
30
+
31
+ Expected layout::
32
+
33
+ {root}/
34
+ extraction/entities/system-v1.0.0.txt
35
+ extraction/entities/user-v1.0.0.txt
36
+ common/thinking_default/fragment-v1.0.0.txt
37
+
38
+ Point ``root`` at any directory that follows this convention::
39
+
40
+ loader = PromptLoader("/path/to/prompts")
41
+ tmpl = loader.load("extraction", "entities", system="1.0.0", user="1.0.0")
42
+ """
43
+
44
+ def __init__(self, root: str | Path) -> None:
45
+ super().__init__()
46
+ self.root = Path(root).expanduser().resolve()
47
+ if not self.root.is_dir():
48
+ raise PromptError(f"Prompt root is not a directory: {self.root}")
49
+
50
+ def path_for(
51
+ self,
52
+ kind: KindLike,
53
+ name: str,
54
+ role: PromptFileRole | str,
55
+ version: VersionLike = DEFAULT_PROMPT_VERSION,
56
+ ) -> Path:
57
+ """
58
+ Resolve ``{root}/{kind}/{name}/{role}-v{version}.txt`` without reading.
59
+ """
60
+ ver = SemanticVersion.parse(version)
61
+ return self.root / str(kind) / name / f"{str(role)}-v{ver}.txt"
62
+
63
+ def read(
64
+ self,
65
+ kind: KindLike,
66
+ name: str,
67
+ role: PromptFileRole | str,
68
+ version: VersionLike = DEFAULT_PROMPT_VERSION,
69
+ ) -> str:
70
+ """
71
+ Read and strip a single prompt file.
72
+ """
73
+ path = self.path_for(kind, name, role, version)
74
+ try:
75
+ return path.read_text(encoding="utf-8").strip()
76
+ except FileNotFoundError as exc:
77
+ raise PromptNotFoundError(f"Prompt file not found: {path}") from exc
78
+ except OSError as exc:
79
+ raise PromptError(f"Failed to read prompt file: {path}") from exc
80
+
81
+ def load_fragment(
82
+ self,
83
+ name: str,
84
+ version: VersionLike = DEFAULT_PROMPT_VERSION,
85
+ ) -> str:
86
+ """
87
+ Load ``common/{name}/fragment-v….txt``.
88
+ """
89
+ return self.read(
90
+ kind=PromptKind.COMMON,
91
+ name=name,
92
+ role=PromptFileRole.FRAGMENT,
93
+ version=version,
94
+ )
95
+
96
+ def load(
97
+ self,
98
+ kind: KindLike,
99
+ name: str,
100
+ *,
101
+ system: VersionLike = DEFAULT_PROMPT_VERSION,
102
+ user: VersionLike | None = DEFAULT_PROMPT_VERSION,
103
+ thinking: ThinkingLevel = ThinkingLevel.DEFAULT,
104
+ ) -> PromptTemplate:
105
+ """
106
+ Load system (+ optional user) files, inject ``{COMMON:…}`` fragments,
107
+ and append a thinking-budget fragment unless ``thinking`` is ``NONE``.
108
+
109
+ Pass ``user=None`` when there is no user template file; ``format_user``
110
+ then expects a raw ``content=`` kwarg.
111
+ """
112
+ self.timing()
113
+ system_prompt = self._inject_common(self.read(kind, name, PromptFileRole.SYSTEM, system))
114
+
115
+ if not thinking.is_none():
116
+ system_prompt = f"{system_prompt}\n\n{self.load_fragment(thinking.fragment_name())}"
117
+
118
+ user_prompt: str | None = None
119
+ if user is not None:
120
+ user_prompt = self.read(kind, name, PromptFileRole.USER, user)
121
+
122
+ template = PromptTemplate(
123
+ kind=str(kind),
124
+ name=name,
125
+ system=system_prompt,
126
+ user=user_prompt,
127
+ )
128
+ self.timing("prompt_loaded")
129
+ return template
130
+
131
+ def _inject_common(self, text: str) -> str:
132
+ """
133
+ Replace ``{COMMON:name}`` tokens with the matching fragment body.
134
+ """
135
+
136
+ def _replace(match: re.Match[str]) -> str:
137
+ return self.load_fragment(match.group(1))
138
+
139
+ return _COMMON_INCLUDE.sub(_replace, text)
@@ -0,0 +1,53 @@
1
+ """
2
+ Loaded system + user prompt pair with ``{variable}`` user formatting.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from corekit.llm.messages import Message, SystemMessage, UserMessage
11
+
12
+ __all__ = ["PromptTemplate"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PromptTemplate:
17
+ """
18
+ A resolved prompt: system text plus an optional user template.
19
+
20
+ User templates use ``str.format`` placeholders (``{text}``, ``{style}``, …).
21
+ Brace-escape values that contain ``{`` / ``}`` so caller content cannot
22
+ corrupt the template. When ``user`` is ``None``, ``format_user`` returns
23
+ the caller's raw ``content`` kwarg.
24
+ """
25
+
26
+ kind: str
27
+ name: str
28
+ system: str
29
+ user: str | None = None
30
+ temperature: float = 0.3
31
+
32
+ def format_user(self, **kwargs: Any) -> str:
33
+ """
34
+ Fill the user template, or return ``kwargs[\"content\"]`` when there is none.
35
+
36
+ Values are stringified and substituted as-is. Literal braces in the
37
+ *template* file must be doubled (``{{`` / ``}}``) per ``str.format``.
38
+ """
39
+ if self.user is None:
40
+ if "content" not in kwargs:
41
+ raise KeyError("Prompt has no user template; pass content=...")
42
+ return str(kwargs["content"])
43
+
44
+ return self.user.format(**{key: str(value) for key, value in kwargs.items()})
45
+
46
+ def messages(self, **kwargs: Any) -> list[Message]:
47
+ """
48
+ Build OpenAI-shaped system + user turns.
49
+ """
50
+ return [
51
+ SystemMessage(content=self.system),
52
+ UserMessage(content=self.format_user(**kwargs)),
53
+ ]
@@ -0,0 +1,65 @@
1
+ """
2
+ Protocols the tool loop injects rather than owning.
3
+
4
+ For a ready-made HTTP implementation, use ``ChatCompletionsClient`` — you only
5
+ need a custom ``ChatClient`` when wrapping an existing SDK or non-HTTP backend.
6
+ """
7
+
8
+ from collections.abc import AsyncIterator, Mapping, Sequence
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ from corekit.llm.messages import ChatTurn
12
+
13
+ __all__ = ["ChatClient", "MetricsRecorder"]
14
+
15
+
16
+ @runtime_checkable
17
+ class ChatClient(Protocol):
18
+ """
19
+ Async OpenAI-compatible chat completions.
20
+
21
+ ``ToolLoop`` always calls ``complete(..., stream=True)`` and ``async for``s
22
+ the result. Messages may be ``Message`` instances or raw wire dicts.
23
+
24
+ Prefer ``corekit.llm.ChatCompletionsClient`` unless you already have a
25
+ stream from another library.
26
+ """
27
+
28
+ def complete(
29
+ self,
30
+ messages: Sequence[ChatTurn],
31
+ *,
32
+ tools: Sequence[Mapping[str, Any]] | None = None,
33
+ model: str | None = None,
34
+ stream: bool = True,
35
+ **kwargs: Any,
36
+ ) -> AsyncIterator[Any]:
37
+ """
38
+ Stream one completion turn (``stream=True``).
39
+ """
40
+ ...
41
+
42
+
43
+ @runtime_checkable
44
+ class MetricsRecorder(Protocol):
45
+ """
46
+ Optional callback after each streamed turn. Implementations may no-op.
47
+ """
48
+
49
+ def record(
50
+ self,
51
+ *,
52
+ model: str | None = None,
53
+ streamed: bool = True,
54
+ succeeded: bool = True,
55
+ prompt_tokens: int | None = None,
56
+ completion_tokens: int | None = None,
57
+ total_tokens: int | None = None,
58
+ time_to_first_token_ms: float | None = None,
59
+ total_duration_ms: float | None = None,
60
+ **kwargs: Any,
61
+ ) -> None:
62
+ """
63
+ Record one completion attempt.
64
+ """
65
+ ...