python-corekit 0.2.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.
Files changed (107) hide show
  1. corekit/api/application.py +47 -9
  2. corekit/api/lifespan.py +26 -3
  3. corekit/concurrency/__init__.py +2 -2
  4. corekit/concurrency/decorators.py +32 -5
  5. corekit/concurrency/thread_local.py +2 -2
  6. corekit/concurrency/worker.py +9 -0
  7. corekit/config/loader.py +42 -5
  8. corekit/config/settings.py +11 -1
  9. corekit/connections/__init__.py +7 -1
  10. corekit/connections/connectable.py +45 -4
  11. corekit/connections/redis/connection.py +53 -10
  12. corekit/connections/sql/__init__.py +2 -1
  13. corekit/connections/sql/connection.py +39 -5
  14. corekit/connections/sql/fields/__init__.py +2 -2
  15. corekit/connections/sql/fields/jsonb.py +13 -6
  16. corekit/connections/sql/migration/__init__.py +4 -0
  17. corekit/connections/sql/migration/operations.py +69 -2
  18. corekit/connections/sql/operations/base.py +11 -2
  19. corekit/connections/sql/operations/statements.py +25 -5
  20. corekit/connections/sql/table.py +7 -29
  21. corekit/crypto/__init__.py +3 -1
  22. corekit/crypto/constants.py +2 -2
  23. corekit/crypto/hasher.py +9 -4
  24. corekit/data/dataset.py +8 -2
  25. corekit/data/expressions/__init__.py +3 -3
  26. corekit/data/expressions/comparison.py +19 -80
  27. corekit/data/expressions/expression.py +0 -32
  28. corekit/data/expressions/operator.py +13 -28
  29. corekit/data/stats.py +3 -0
  30. corekit/decorators/exception_handling.py +36 -8
  31. corekit/docker/watchdog.py +50 -31
  32. corekit/etl/__init__.py +2 -1
  33. corekit/etl/connection.py +14 -12
  34. corekit/etl/extract/extractor.py +6 -13
  35. corekit/etl/orchestrator.py +19 -2
  36. corekit/etl/schemas.py +2 -2
  37. corekit/etl/transform/transformer.py +4 -1
  38. corekit/events/publisher.py +1 -1
  39. corekit/events/reader.py +26 -21
  40. corekit/events/sse.py +4 -1
  41. corekit/events/websocket.py +24 -11
  42. corekit/exceptions/__init__.py +24 -9
  43. corekit/exceptions/base.py +139 -10
  44. corekit/exceptions/enum.py +17 -0
  45. corekit/exceptions/types.py +6 -6
  46. corekit/files/__init__.py +2 -4
  47. corekit/files/base.py +15 -2
  48. corekit/files/enum.py +0 -5
  49. corekit/files/json.py +16 -2
  50. corekit/http/__init__.py +48 -5
  51. corekit/http/api.py +24 -0
  52. corekit/http/client.py +133 -75
  53. corekit/http/exceptions.py +140 -0
  54. corekit/http/response.py +50 -1
  55. corekit/http/status.py +89 -0
  56. corekit/http/stream.py +110 -0
  57. corekit/jobs/runner.py +12 -1
  58. corekit/jobs/task.py +23 -2
  59. corekit/llm/__init__.py +134 -0
  60. corekit/llm/client.py +179 -0
  61. corekit/llm/enum.py +123 -0
  62. corekit/llm/events.py +96 -0
  63. corekit/llm/messages.py +173 -0
  64. corekit/llm/prompts/__init__.py +19 -0
  65. corekit/llm/prompts/enum.py +54 -0
  66. corekit/llm/prompts/exceptions.py +22 -0
  67. corekit/llm/prompts/loader.py +139 -0
  68. corekit/llm/prompts/template.py +53 -0
  69. corekit/llm/protocols.py +65 -0
  70. corekit/llm/streaming.py +149 -0
  71. corekit/llm/tools/__init__.py +19 -0
  72. corekit/llm/tools/base.py +118 -0
  73. corekit/llm/tools/detection.py +99 -0
  74. corekit/llm/tools/loop.py +255 -0
  75. corekit/llm/tools/registry.py +103 -0
  76. corekit/llm/wire.py +199 -0
  77. corekit/log_monitor/models.py +8 -2
  78. corekit/log_monitor/service.py +77 -38
  79. corekit/notifications/base.py +18 -10
  80. corekit/observability/__init__.py +12 -5
  81. corekit/observability/benchmarkable.py +37 -5
  82. corekit/observability/loggable.py +21 -0
  83. corekit/observability/request_context.py +55 -2
  84. corekit/observability/timing/split.py +14 -0
  85. corekit/observability/timing/timer.py +33 -9
  86. corekit/registry/__init__.py +2 -2
  87. corekit/registry/registry.py +55 -14
  88. corekit/schemas/__init__.py +2 -1
  89. corekit/schemas/enum.py +22 -1
  90. corekit/schemas/types.py +6 -1
  91. corekit/schemas/version.py +58 -0
  92. corekit/serialization/__init__.py +2 -0
  93. corekit/serialization/pickle_file.py +61 -0
  94. corekit/serialization/serializable.py +22 -2
  95. corekit/serialization/serializer.py +9 -2
  96. corekit/utils/__init__.py +2 -1
  97. corekit/utils/collections.py +38 -14
  98. corekit/utils/payload.py +12 -0
  99. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/METADATA +38 -9
  100. python_corekit-0.4.0.dist-info/RECORD +165 -0
  101. corekit/constants.py +0 -45
  102. corekit/exceptions/http/exceptions.py +0 -37
  103. corekit/files/pickle.py +0 -12
  104. python_corekit-0.2.0.dist-info/RECORD +0 -143
  105. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/WHEEL +0 -0
  106. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/licenses/LICENSE +0 -0
  107. {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/top_level.txt +0 -0
@@ -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
+ ...
@@ -0,0 +1,149 @@
1
+ """
2
+ Think-tag streaming helpers for llama.cpp-style ``<think>`` content.
3
+ """
4
+
5
+ import re
6
+ from collections.abc import Generator, Iterator
7
+ from enum import Enum
8
+ from typing import NamedTuple
9
+
10
+ from corekit.llm.events import StreamEvent, TextEvent, ThinkingEvent
11
+
12
+ __all__ = [
13
+ "THINK_PATTERN",
14
+ "ModelStreamingState",
15
+ "ThinkingMarker",
16
+ "ThinkingMarkerPair",
17
+ "strip_thinking",
18
+ ]
19
+
20
+ THINK_PATTERN = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
21
+
22
+
23
+ def strip_thinking(text: str) -> str:
24
+ """
25
+ Remove complete ``<think>…</think>`` blocks from a finished string.
26
+ """
27
+ return THINK_PATTERN.sub("", text).strip()
28
+
29
+
30
+ class ThinkingMarkerPair(NamedTuple):
31
+ start: str
32
+ end: str
33
+
34
+
35
+ class ThinkingMarker(Enum):
36
+ """
37
+ Delimiter pairs for thinking regions in model content.
38
+ """
39
+
40
+ XML_THINK = ThinkingMarkerPair(start="<think>", end="</think>")
41
+
42
+ @property
43
+ def start(self) -> str:
44
+ return self.value.start
45
+
46
+ @property
47
+ def end(self) -> str:
48
+ return self.value.end
49
+
50
+
51
+ class ModelStreamingState:
52
+ """
53
+ Tracks ``<think>…</think>`` (or other marker) parse state across chunks.
54
+ """
55
+
56
+ def __init__(self, marker: ThinkingMarker = ThinkingMarker.XML_THINK) -> None:
57
+ self.marker = marker
58
+ self.inside = False
59
+ self.text_buffer = ""
60
+ self.think_buffer = ""
61
+ self._complete_buffer = ""
62
+ self._pending = "" # held-back prefix of an open/close marker
63
+
64
+ def format(self) -> str:
65
+ return (
66
+ f"ModelStreamingState(text={self.text_buffer!r}, "
67
+ f"think={self.think_buffer!r}, complete={self._complete_buffer!r})"
68
+ )
69
+
70
+ def feed(self, delta: str) -> Iterator[StreamEvent]:
71
+ """
72
+ Parse one content delta; yield text / thinking events and update buffers.
73
+ """
74
+ buf = self._pending + delta
75
+ self._pending = ""
76
+
77
+ while buf:
78
+ if self.inside:
79
+ buf = yield from self._feed_inside(buf)
80
+ else:
81
+ buf = yield from self._feed_outside(buf)
82
+
83
+ def _feed_outside(self, buf: str) -> Generator[StreamEvent, None, str]:
84
+ """
85
+ Emit text until a start marker; return leftover buffer.
86
+ """
87
+ idx = buf.find(self.marker.start)
88
+ if idx != -1:
89
+ before = buf[:idx]
90
+ if before:
91
+ self._append_text(before)
92
+ yield TextEvent(content=before)
93
+ self.inside = True
94
+ return buf[idx + len(self.marker.start) :]
95
+
96
+ partial = self._partial_tag_suffix(buf, self.marker.start)
97
+ if partial:
98
+ emit = buf[: -len(partial)]
99
+ if emit:
100
+ self._append_text(emit)
101
+ yield TextEvent(content=emit)
102
+ self._pending = partial
103
+ else:
104
+ self._append_text(buf)
105
+ yield TextEvent(content=buf)
106
+ return ""
107
+
108
+ def _feed_inside(self, buf: str) -> Generator[StreamEvent, None, str]:
109
+ """
110
+ Emit thinking until an end marker; return leftover buffer.
111
+ """
112
+ idx = buf.find(self.marker.end)
113
+ if idx != -1:
114
+ thought = buf[:idx]
115
+ if thought:
116
+ self._append_think(thought)
117
+ yield ThinkingEvent(content=thought)
118
+ self.inside = False
119
+ return buf[idx + len(self.marker.end) :].lstrip()
120
+
121
+ partial = self._partial_tag_suffix(buf, self.marker.end)
122
+ if partial:
123
+ emit = buf[: -len(partial)]
124
+ if emit:
125
+ self._append_think(emit)
126
+ yield ThinkingEvent(content=emit)
127
+ self._pending = partial
128
+ else:
129
+ self._append_think(buf)
130
+ yield ThinkingEvent(content=buf)
131
+ return ""
132
+
133
+ def _append_text(self, new: str) -> None:
134
+ self.text_buffer += new
135
+ self._complete_buffer += new
136
+
137
+ def _append_think(self, new: str) -> None:
138
+ self.think_buffer += new
139
+ self._complete_buffer += new
140
+
141
+ @staticmethod
142
+ def _partial_tag_suffix(text: str, tag: str) -> str:
143
+ """
144
+ Longest non-empty suffix of ``text`` that is a prefix of ``tag``.
145
+ """
146
+ for i in range(min(len(tag) - 1, len(text)), 0, -1):
147
+ if text.endswith(tag[:i]):
148
+ return text[-i:]
149
+ return ""
@@ -0,0 +1,19 @@
1
+ """
2
+ Tool vocabulary, registry, and detectors.
3
+
4
+ ``ToolLoop`` lives in ``corekit.llm.tools.loop`` and is re-exported from
5
+ ``corekit.llm`` — importing it here would cycle through ``events``.
6
+ """
7
+
8
+ from corekit.llm.tools.base import Tool, ToolCall, ToolResult
9
+ from corekit.llm.tools.detection import MAX_TOOL_ITERATIONS, ToolCallLoopDetector
10
+ from corekit.llm.tools.registry import ToolRegistry
11
+
12
+ __all__ = [
13
+ "MAX_TOOL_ITERATIONS",
14
+ "Tool",
15
+ "ToolCall",
16
+ "ToolCallLoopDetector",
17
+ "ToolRegistry",
18
+ "ToolResult",
19
+ ]