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/http/__init__.py +5 -0
- corekit/http/client.py +34 -3
- corekit/http/stream.py +110 -0
- corekit/llm/__init__.py +134 -0
- corekit/llm/client.py +179 -0
- corekit/llm/enum.py +123 -0
- corekit/llm/events.py +96 -0
- corekit/llm/messages.py +173 -0
- corekit/llm/prompts/__init__.py +19 -0
- corekit/llm/prompts/enum.py +54 -0
- corekit/llm/prompts/exceptions.py +22 -0
- corekit/llm/prompts/loader.py +139 -0
- corekit/llm/prompts/template.py +53 -0
- corekit/llm/protocols.py +65 -0
- corekit/llm/streaming.py +149 -0
- corekit/llm/tools/__init__.py +19 -0
- corekit/llm/tools/base.py +118 -0
- corekit/llm/tools/detection.py +99 -0
- corekit/llm/tools/loop.py +255 -0
- corekit/llm/tools/registry.py +103 -0
- corekit/llm/wire.py +199 -0
- corekit/observability/__init__.py +3 -3
- corekit/observability/benchmarkable.py +16 -2
- corekit/observability/timing/split.py +14 -0
- corekit/observability/timing/timer.py +31 -9
- corekit/schemas/__init__.py +2 -1
- corekit/schemas/version.py +58 -0
- corekit/utils/__init__.py +2 -1
- corekit/utils/collections.py +16 -1
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/METADATA +33 -4
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/RECORD +34 -14
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.3.0.dist-info → python_corekit-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry of callable tools for an agentic loop.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from corekit.llm.enum import ToolKind
|
|
9
|
+
from corekit.llm.tools.base import Tool, ToolResult
|
|
10
|
+
from corekit.registry import SmartRegistry
|
|
11
|
+
|
|
12
|
+
__all__ = ["ToolRegistry"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolRegistry(SmartRegistry):
|
|
16
|
+
"""
|
|
17
|
+
Named tools available to a chat completion. Starts empty — the consumer
|
|
18
|
+
registers what it wants; there is no default population here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def register(self, tool: Tool) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Add a tool under its ``name``. Raises if the name is already taken.
|
|
24
|
+
"""
|
|
25
|
+
if tool.name in self:
|
|
26
|
+
raise ValueError(f"Tool already registered: {tool.name}")
|
|
27
|
+
self[tool.name] = tool
|
|
28
|
+
|
|
29
|
+
def list_tools(self) -> list[Tool]:
|
|
30
|
+
"""
|
|
31
|
+
All registered tools, in registry iteration order.
|
|
32
|
+
"""
|
|
33
|
+
return list(self.values())
|
|
34
|
+
|
|
35
|
+
def filtered(self, predicate: Callable[[Tool], bool]) -> "ToolRegistry":
|
|
36
|
+
"""
|
|
37
|
+
Return a new registry containing only tools matching ``predicate``.
|
|
38
|
+
|
|
39
|
+
Does not mutate this registry. Use this instead of product-specific
|
|
40
|
+
scope enums — the consumer owns the filter (e.g. by attribute or tag).
|
|
41
|
+
"""
|
|
42
|
+
result = ToolRegistry()
|
|
43
|
+
for tool in self.values():
|
|
44
|
+
if predicate(tool):
|
|
45
|
+
result[tool.name] = tool
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def openai_schemas(self) -> list[dict[str, Any]]:
|
|
49
|
+
"""
|
|
50
|
+
OpenAI function-tool schemas for every registered tool.
|
|
51
|
+
"""
|
|
52
|
+
return [t.to_openai_schema() for t in self.values()]
|
|
53
|
+
|
|
54
|
+
def execute(self, name: str, tool_call_id: str, arguments: dict[str, Any]) -> ToolResult:
|
|
55
|
+
"""
|
|
56
|
+
Sync lookup and run. Prefer ``aexecute`` inside async loops.
|
|
57
|
+
"""
|
|
58
|
+
tool = self.get(name)
|
|
59
|
+
if tool is None:
|
|
60
|
+
return self._unknown_result(name, tool_call_id)
|
|
61
|
+
try:
|
|
62
|
+
return tool.execute(arguments).with_call_id(tool_call_id)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return self._error_result(name, tool_call_id, e)
|
|
65
|
+
|
|
66
|
+
async def aexecute(self, name: str, tool_call_id: str, arguments: dict[str, Any]) -> ToolResult:
|
|
67
|
+
"""
|
|
68
|
+
Async lookup and run via ``Tool.aexecute``.
|
|
69
|
+
"""
|
|
70
|
+
tool = self.get(name)
|
|
71
|
+
if tool is None:
|
|
72
|
+
return self._unknown_result(name, tool_call_id)
|
|
73
|
+
try:
|
|
74
|
+
result = await tool.aexecute(arguments)
|
|
75
|
+
return result.with_call_id(tool_call_id)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return self._error_result(name, tool_call_id, e)
|
|
78
|
+
|
|
79
|
+
def get_tool_kind(self, name: str) -> ToolKind | None:
|
|
80
|
+
"""
|
|
81
|
+
Kind of the named tool, or None if unregistered.
|
|
82
|
+
"""
|
|
83
|
+
if tool := self.get(name):
|
|
84
|
+
return tool.kind
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _unknown_result(name: str, tool_call_id: str) -> ToolResult:
|
|
89
|
+
return ToolResult(
|
|
90
|
+
tool_call_id=tool_call_id,
|
|
91
|
+
name=name,
|
|
92
|
+
content=f"Unknown tool: {name}",
|
|
93
|
+
is_error=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _error_result(name: str, tool_call_id: str, error: Exception) -> ToolResult:
|
|
98
|
+
return ToolResult(
|
|
99
|
+
tool_call_id=tool_call_id,
|
|
100
|
+
name=name,
|
|
101
|
+
content=f"Tool execution error: {error}",
|
|
102
|
+
is_error=True,
|
|
103
|
+
)
|
corekit/llm/wire.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured OpenAI completion-chunk parsing and tool-call accumulation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from corekit.llm.enum import WireField
|
|
14
|
+
from corekit.llm.events import UsageEvent
|
|
15
|
+
from corekit.llm.tools.base import ToolCall
|
|
16
|
+
from corekit.utils import safe_dict, safe_string
|
|
17
|
+
from corekit.utils.collections import attr_or_key
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ChoiceDelta",
|
|
21
|
+
"CompletionTurn",
|
|
22
|
+
"FunctionCallDelta",
|
|
23
|
+
"ToolCallAccumulator",
|
|
24
|
+
"ToolCallDelta",
|
|
25
|
+
"UsageInfo",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FunctionCallDelta(BaseModel):
|
|
32
|
+
"""
|
|
33
|
+
Incremental ``function`` fields on a streamed tool-call delta.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str = ""
|
|
37
|
+
arguments: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToolCallDelta(BaseModel):
|
|
41
|
+
"""
|
|
42
|
+
One streamed tool-call fragment (may arrive across many chunks).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
index: int = 0
|
|
46
|
+
id: str = ""
|
|
47
|
+
function: FunctionCallDelta = Field(default_factory=FunctionCallDelta)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_raw(cls, raw: Any) -> ToolCallDelta | None:
|
|
51
|
+
"""
|
|
52
|
+
Parse a dict or SDK object into a delta; return None if index is missing.
|
|
53
|
+
"""
|
|
54
|
+
index = attr_or_key(raw, WireField.INDEX)
|
|
55
|
+
if index is None:
|
|
56
|
+
return None
|
|
57
|
+
function_raw = safe_dict(attr_or_key(raw, WireField.FUNCTION))
|
|
58
|
+
return cls(
|
|
59
|
+
index=int(index),
|
|
60
|
+
id=safe_string(attr_or_key(raw, WireField.ID)),
|
|
61
|
+
function=FunctionCallDelta(
|
|
62
|
+
name=safe_string(attr_or_key(function_raw, WireField.NAME)),
|
|
63
|
+
arguments=safe_string(attr_or_key(function_raw, WireField.ARGUMENTS)),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ToolCallAccumulator(BaseModel):
|
|
69
|
+
"""
|
|
70
|
+
Merges streamed tool-call deltas for a single index into one call.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id: str = ""
|
|
74
|
+
name: str = ""
|
|
75
|
+
arguments: str = ""
|
|
76
|
+
|
|
77
|
+
def merge(self, delta: ToolCallDelta) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Append name/argument fragments from one delta.
|
|
80
|
+
"""
|
|
81
|
+
if delta.id:
|
|
82
|
+
self.id = delta.id
|
|
83
|
+
if delta.function.name:
|
|
84
|
+
self.name += delta.function.name
|
|
85
|
+
if delta.function.arguments:
|
|
86
|
+
self.arguments += delta.function.arguments
|
|
87
|
+
|
|
88
|
+
def to_tool_call(self) -> ToolCall:
|
|
89
|
+
"""
|
|
90
|
+
Parse accumulated argument JSON into a ``ToolCall``.
|
|
91
|
+
"""
|
|
92
|
+
parsed: dict[str, Any] = {}
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(self.arguments)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
logger.warning("Bad tool args for %r: %r", self.name, self.arguments)
|
|
97
|
+
return ToolCall(id=self.id, name=self.name, arguments=safe_dict(parsed))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ChoiceDelta(BaseModel):
|
|
101
|
+
"""
|
|
102
|
+
``choices[0].delta`` fields we care about from one chunk.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
content: str | None = None
|
|
106
|
+
reasoning_content: str | None = None
|
|
107
|
+
tool_calls: list[ToolCallDelta] = Field(default_factory=list)
|
|
108
|
+
finish_reason: str | None = None
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_chunk(cls, chunk: Any) -> ChoiceDelta | None:
|
|
112
|
+
"""
|
|
113
|
+
Extract the first choice's delta (and finish_reason) from a chunk.
|
|
114
|
+
"""
|
|
115
|
+
choices = attr_or_key(chunk, WireField.CHOICES)
|
|
116
|
+
if not choices:
|
|
117
|
+
return None
|
|
118
|
+
choice = choices[0]
|
|
119
|
+
delta_raw = attr_or_key(choice, WireField.DELTA)
|
|
120
|
+
if delta_raw is None:
|
|
121
|
+
return cls(finish_reason=attr_or_key(choice, WireField.FINISH_REASON))
|
|
122
|
+
|
|
123
|
+
tool_calls: list[ToolCallDelta] = []
|
|
124
|
+
for raw_tc in attr_or_key(delta_raw, WireField.TOOL_CALLS) or []:
|
|
125
|
+
parsed = ToolCallDelta.from_raw(raw_tc)
|
|
126
|
+
if parsed is not None:
|
|
127
|
+
tool_calls.append(parsed)
|
|
128
|
+
|
|
129
|
+
return cls(
|
|
130
|
+
content=attr_or_key(delta_raw, WireField.CONTENT),
|
|
131
|
+
reasoning_content=attr_or_key(delta_raw, WireField.REASONING_CONTENT),
|
|
132
|
+
tool_calls=tool_calls,
|
|
133
|
+
finish_reason=attr_or_key(choice, WireField.FINISH_REASON),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class UsageInfo(BaseModel):
|
|
138
|
+
"""
|
|
139
|
+
Token usage from a completion chunk or final response.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
prompt_tokens: int | None = None
|
|
143
|
+
completion_tokens: int | None = None
|
|
144
|
+
total_tokens: int | None = None
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_chunk(cls, chunk: Any) -> UsageInfo | None:
|
|
148
|
+
"""
|
|
149
|
+
Read ``usage`` from a chunk when present.
|
|
150
|
+
"""
|
|
151
|
+
usage = attr_or_key(chunk, WireField.USAGE)
|
|
152
|
+
if usage is None:
|
|
153
|
+
return None
|
|
154
|
+
return cls(
|
|
155
|
+
prompt_tokens=attr_or_key(usage, WireField.PROMPT_TOKENS),
|
|
156
|
+
completion_tokens=attr_or_key(usage, WireField.COMPLETION_TOKENS),
|
|
157
|
+
total_tokens=attr_or_key(usage, WireField.TOTAL_TOKENS),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def to_event(self) -> UsageEvent:
|
|
161
|
+
"""
|
|
162
|
+
Convert to a stream ``UsageEvent``.
|
|
163
|
+
"""
|
|
164
|
+
return UsageEvent(
|
|
165
|
+
total_tokens=self.total_tokens,
|
|
166
|
+
prompt_tokens=self.prompt_tokens,
|
|
167
|
+
completion_tokens=self.completion_tokens,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class CompletionTurn(BaseModel):
|
|
172
|
+
"""
|
|
173
|
+
Mutable state accumulated while streaming one ``complete()`` call.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
tool_calls: dict[int, ToolCallAccumulator] = Field(default_factory=dict)
|
|
177
|
+
usage: UsageInfo | None = None
|
|
178
|
+
finish_reason: str | None = None
|
|
179
|
+
first_token_ms: float | None = None
|
|
180
|
+
|
|
181
|
+
def absorb_delta(self, delta: ChoiceDelta) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Merge one choice delta into this turn.
|
|
184
|
+
"""
|
|
185
|
+
if delta.finish_reason:
|
|
186
|
+
self.finish_reason = delta.finish_reason
|
|
187
|
+
for tool_delta in delta.tool_calls:
|
|
188
|
+
bucket = self.tool_calls.setdefault(tool_delta.index, ToolCallAccumulator())
|
|
189
|
+
bucket.merge(tool_delta)
|
|
190
|
+
|
|
191
|
+
def absorb_usage(self, usage: UsageInfo | None) -> None:
|
|
192
|
+
if usage is not None:
|
|
193
|
+
self.usage = usage
|
|
194
|
+
|
|
195
|
+
def parsed_tool_calls(self) -> list[ToolCall]:
|
|
196
|
+
"""
|
|
197
|
+
Finalize accumulated tool-call indices into ``ToolCall`` values.
|
|
198
|
+
"""
|
|
199
|
+
return [self.tool_calls[idx].to_tool_call() for idx in sorted(self.tool_calls)]
|
|
@@ -8,10 +8,10 @@ are the same concern -- knowing what a running system is doing.
|
|
|
8
8
|
|
|
9
9
|
class Importer(Benchmarkable):
|
|
10
10
|
def run(self) -> None:
|
|
11
|
-
self.
|
|
12
|
-
self.
|
|
11
|
+
self.reset_timing()
|
|
12
|
+
self.timing("started")
|
|
13
13
|
...
|
|
14
|
-
self.timing("finished")
|
|
14
|
+
self.timing("finished") # total since reset; latest since started
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
@@ -18,12 +18,26 @@ class Benchmarkable(Loggable):
|
|
|
18
18
|
super().__init__(*args, **kwargs)
|
|
19
19
|
self._timing = Timer()
|
|
20
20
|
|
|
21
|
+
def reset_timing(self) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Restart the underlying timer for a new unit of work.
|
|
24
|
+
"""
|
|
25
|
+
self._timing.reset()
|
|
26
|
+
|
|
27
|
+
def elapsed_ms(self) -> float:
|
|
28
|
+
"""
|
|
29
|
+
Milliseconds since start (or last ``reset_timing``), without a split.
|
|
30
|
+
"""
|
|
31
|
+
return self._timing.elapsed() * 1000
|
|
32
|
+
|
|
21
33
|
def timing(self, split_name: str | None = None) -> Split:
|
|
22
34
|
"""
|
|
23
35
|
Log the time since the last split and return it.
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
``Split.total`` / ``Split.total_ms`` are since start (or last reset).
|
|
38
|
+
``Split.latest`` / ``Split.latest_ms`` are since the previous split.
|
|
39
|
+
The log line is unchanged. The return value is for a caller that wants
|
|
40
|
+
the numbers without parsing that line.
|
|
27
41
|
"""
|
|
28
42
|
split = self._timing.split(split_name=split_name)
|
|
29
43
|
self.info(str(split))
|
|
@@ -8,6 +8,20 @@ class Split(BaseModel):
|
|
|
8
8
|
name: str | None = None
|
|
9
9
|
split_name: str | None = None
|
|
10
10
|
|
|
11
|
+
@property
|
|
12
|
+
def total_ms(self) -> float:
|
|
13
|
+
"""
|
|
14
|
+
``total`` in milliseconds.
|
|
15
|
+
"""
|
|
16
|
+
return self.total * 1000
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def latest_ms(self) -> float:
|
|
20
|
+
"""
|
|
21
|
+
``latest`` in milliseconds.
|
|
22
|
+
"""
|
|
23
|
+
return self.latest * 1000
|
|
24
|
+
|
|
11
25
|
def __str__(self) -> str:
|
|
12
26
|
identifier = ""
|
|
13
27
|
if self.name:
|
|
@@ -5,28 +5,50 @@ from corekit.observability.timing.split import Split
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class Timer:
|
|
8
|
+
start: float
|
|
9
|
+
latest: float
|
|
10
|
+
num: int
|
|
11
|
+
precision: int
|
|
12
|
+
name: str
|
|
13
|
+
|
|
8
14
|
def __init__(self, precision: int = DEFAULT_PRECISION) -> None:
|
|
9
15
|
# perf_counter is monotonic. time.time() can step backwards, which
|
|
10
16
|
# makes a split look negative for no reason the caller can act on.
|
|
11
|
-
_time = time.perf_counter()
|
|
12
|
-
self.start = _time
|
|
13
|
-
self.latest = _time
|
|
14
|
-
self.num = 0
|
|
15
|
-
self.name = type(self).__name__
|
|
16
17
|
self.precision = precision if precision > 0 else DEFAULT_PRECISION
|
|
18
|
+
self.name = type(self).__name__
|
|
19
|
+
self.reset()
|
|
20
|
+
|
|
21
|
+
def reset(self) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Restart the clock and clear split numbering.
|
|
24
|
+
|
|
25
|
+
Use at the start of a new timed unit of work (a request, a model turn,
|
|
26
|
+
a pipeline stage) so ``total`` measures that unit rather than the
|
|
27
|
+
object's whole lifetime.
|
|
28
|
+
"""
|
|
29
|
+
now = time.perf_counter()
|
|
30
|
+
self.start = now
|
|
31
|
+
self.latest = now
|
|
32
|
+
self.num = 0
|
|
17
33
|
|
|
18
34
|
def _round(self, value: float) -> float:
|
|
19
35
|
return round(value, self.precision)
|
|
20
36
|
|
|
37
|
+
def elapsed(self) -> float:
|
|
38
|
+
"""
|
|
39
|
+
Seconds since start (or last ``reset``), without recording a split.
|
|
40
|
+
"""
|
|
41
|
+
return self._round(time.perf_counter() - self.start)
|
|
42
|
+
|
|
21
43
|
def split(self, split_name: str | None = None) -> Split:
|
|
22
|
-
|
|
44
|
+
now = time.perf_counter()
|
|
23
45
|
self.num += 1
|
|
24
46
|
split = Split(
|
|
25
47
|
num=self.num,
|
|
26
|
-
total=self._round(
|
|
27
|
-
latest=self._round(
|
|
48
|
+
total=self._round(now - self.start),
|
|
49
|
+
latest=self._round(now - self.latest),
|
|
28
50
|
name=self.name,
|
|
29
51
|
split_name=split_name,
|
|
30
52
|
)
|
|
31
|
-
self.latest =
|
|
53
|
+
self.latest = now
|
|
32
54
|
return split
|
corekit/schemas/__init__.py
CHANGED
|
@@ -6,5 +6,6 @@ raises where it is introduced rather than somewhere further along.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from corekit.schemas.enum import IntegerEnum, StringEnum, ValidatingEnum
|
|
9
|
+
from corekit.schemas.version import SemanticVersion
|
|
9
10
|
|
|
10
|
-
__all__ = ["IntegerEnum", "StringEnum", "ValidatingEnum"]
|
|
11
|
+
__all__ = ["IntegerEnum", "SemanticVersion", "StringEnum", "ValidatingEnum"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic version (``major.minor.patch`` with optional ``-extra``).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Self
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from corekit.utils import safe_int, safe_string
|
|
13
|
+
|
|
14
|
+
__all__ = ["SemanticVersion"]
|
|
15
|
+
|
|
16
|
+
_VERSION_RE = re.compile(r"^(?P<major>\d+)(?:\.(?P<minor>\d+)(?:\.(?P<patch>\d+)?)?)?(?:-(?P<extra>[A-Za-z0-9._-]+))?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SemanticVersion(BaseModel):
|
|
20
|
+
"""
|
|
21
|
+
Structured ``major.minor.patch`` with an optional ``-extra`` suffix.
|
|
22
|
+
|
|
23
|
+
Suitable for filenames, config pins, and other places that need a parsed
|
|
24
|
+
SemVer-like value without tying to packaging metadata.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
major: int = Field(ge=0)
|
|
28
|
+
minor: int = Field(default=0, ge=0)
|
|
29
|
+
patch: int = Field(default=0, ge=0)
|
|
30
|
+
extra: str | None = None
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
version = f"{self.major}.{self.minor}.{self.patch}"
|
|
34
|
+
if self.extra:
|
|
35
|
+
return f"{version}-{self.extra}"
|
|
36
|
+
return version
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"SemanticVersion({self})"
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse(cls, value: SemanticVersion | str | int) -> Self:
|
|
43
|
+
"""
|
|
44
|
+
Accept a version object, ``\"1.0.0\"``, or a bare major ``1`` (→ ``1.0.0``).
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(value, SemanticVersion):
|
|
47
|
+
return value
|
|
48
|
+
if isinstance(value, int):
|
|
49
|
+
return cls(major=value)
|
|
50
|
+
match = _VERSION_RE.fullmatch(safe_string(value))
|
|
51
|
+
if match is None:
|
|
52
|
+
raise ValueError(f"Invalid semantic version: {value!r}")
|
|
53
|
+
return cls(
|
|
54
|
+
major=safe_int(match.group("major")),
|
|
55
|
+
minor=safe_int(match.group("minor")),
|
|
56
|
+
patch=safe_int(match.group("patch")),
|
|
57
|
+
extra=match.group("extra"),
|
|
58
|
+
)
|
corekit/utils/__init__.py
CHANGED
|
@@ -9,7 +9,7 @@ configuration need.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from corekit.utils.coercion import safe_dict, safe_float, safe_int, safe_list, safe_string, safe_tuple
|
|
12
|
-
from corekit.utils.collections import UNSET, MultiMatch, keygetter, repeated_get, split_list
|
|
12
|
+
from corekit.utils.collections import UNSET, MultiMatch, attr_or_key, keygetter, repeated_get, split_list
|
|
13
13
|
from corekit.utils.ids import (
|
|
14
14
|
SHORTCODE_CHARS,
|
|
15
15
|
generate_session_token,
|
|
@@ -29,6 +29,7 @@ __all__ = [
|
|
|
29
29
|
"UNSET",
|
|
30
30
|
"MultiMatch",
|
|
31
31
|
"Payload",
|
|
32
|
+
"attr_or_key",
|
|
32
33
|
"decode_payload",
|
|
33
34
|
"encode_payload",
|
|
34
35
|
"false_validator",
|
corekit/utils/collections.py
CHANGED
|
@@ -10,7 +10,7 @@ missing or the wrong type.
|
|
|
10
10
|
from copy import deepcopy
|
|
11
11
|
from typing import Any, Callable
|
|
12
12
|
|
|
13
|
-
__all__ = ["MultiMatch", "keygetter", "repeated_get", "split_list"]
|
|
13
|
+
__all__ = ["MultiMatch", "attr_or_key", "keygetter", "repeated_get", "split_list"]
|
|
14
14
|
|
|
15
15
|
KEY_SEPARATOR = "."
|
|
16
16
|
|
|
@@ -25,6 +25,21 @@ def split_list(items: list[Any], index: int) -> tuple[list[Any], list[Any]]:
|
|
|
25
25
|
return items[:index], items[index:]
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def attr_or_key(obj: Any, name: str) -> Any:
|
|
29
|
+
"""
|
|
30
|
+
Read ``name`` from a mapping or an object attribute.
|
|
31
|
+
|
|
32
|
+
Returns ``None`` when ``obj`` is ``None``, the key is missing, or the
|
|
33
|
+
attribute is absent. Useful for SDK payloads that arrive as either dicts
|
|
34
|
+
or attribute-bearing chunk objects.
|
|
35
|
+
"""
|
|
36
|
+
if obj is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(obj, dict):
|
|
39
|
+
return obj.get(name)
|
|
40
|
+
return getattr(obj, name, None)
|
|
41
|
+
|
|
42
|
+
|
|
28
43
|
class MultiMatch(dict[str, Any]):
|
|
29
44
|
"""
|
|
30
45
|
The result of a path step that matched more than one key.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-corekit
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, and
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, ETL, and LLM chat/tool primitives
|
|
5
5
|
Author: Steven Jacobsen
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/stevejaker/corekit
|
|
@@ -18,6 +18,7 @@ License-File: LICENSE
|
|
|
18
18
|
Requires-Dist: pydantic<3,>=2.10
|
|
19
19
|
Requires-Dist: pydantic-settings<3,>=2.0
|
|
20
20
|
Requires-Dist: fastapi<1,>=0.115
|
|
21
|
+
Requires-Dist: starlette<0.47,>=0.40
|
|
21
22
|
Requires-Dist: sqlmodel<0.1,>=0.0.16
|
|
22
23
|
Requires-Dist: SQLAlchemy<3,>=2.0
|
|
23
24
|
Requires-Dist: redis<7,>=5.0
|
|
@@ -37,7 +38,8 @@ Dynamic: license-file
|
|
|
37
38
|
|
|
38
39
|
Shared foundations for Python projects: structured logging, benchmarking,
|
|
39
40
|
registries, a FastAPI application with routers and handlers, SQL statements and
|
|
40
|
-
migrations, background tasks, an in-memory record store,
|
|
41
|
+
migrations, background tasks, an in-memory record store, ETL scaffolding, and
|
|
42
|
+
OpenAI-shaped chat / tool-loop primitives.
|
|
41
43
|
|
|
42
44
|
Requires Python 3.11+.
|
|
43
45
|
|
|
@@ -55,7 +57,7 @@ extras to remember, and no import that fails because something was left out.
|
|
|
55
57
|
Pin a compatible release rather than tracking whatever is newest:
|
|
56
58
|
|
|
57
59
|
```
|
|
58
|
-
python-corekit~=0.
|
|
60
|
+
python-corekit~=0.4.0
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
Before 1.0, the minor version carries breaking changes.
|
|
@@ -281,6 +283,32 @@ ceiling, not 9,999 threads.
|
|
|
281
283
|
Failures propagate by default. Pass `raise_on_error=False` to log and skip them
|
|
282
284
|
instead, which loses results silently and so is opt-in.
|
|
283
285
|
|
|
286
|
+
## LLM chat and tools
|
|
287
|
+
|
|
288
|
+
OpenAI-shaped messages, a tool registry, an agentic loop, and a thin
|
|
289
|
+
``ChatCompletionsClient`` (a ``BaseApiClient``) for any OpenAI-compatible
|
|
290
|
+
server — llama.cpp, vLLM, OpenAI, and so on. Not a multi-provider generation
|
|
291
|
+
facade.
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
from corekit.llm import ChatCompletionsClient, PromptLoader, ToolLoop, ToolRegistry
|
|
295
|
+
|
|
296
|
+
client = ChatCompletionsClient("http://localhost:8080/v1", model="local")
|
|
297
|
+
prompts = PromptLoader("/path/to/prompts") # mount any compatible tree
|
|
298
|
+
tmpl = prompts.load("summarization", "general", system="1.0.0", user="1.0.0")
|
|
299
|
+
|
|
300
|
+
registry = ToolRegistry()
|
|
301
|
+
registry.register(MyTool())
|
|
302
|
+
loop = ToolLoop(client, registry)
|
|
303
|
+
async for event in loop.stream(tmpl.messages(text="…", style="brief", length="short")):
|
|
304
|
+
...
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
``PromptLoader`` reads versioned ``.txt`` files from a directory you point at
|
|
308
|
+
(``{kind}/{name}/{role}-v{version}.txt``, plus ``common/`` fragments). Prompt
|
|
309
|
+
*text* stays with the consumer — corekit only ships the loader. See
|
|
310
|
+
`docs/LLM_EXTRACTION.md`.
|
|
311
|
+
|
|
284
312
|
## HTTP clients
|
|
285
313
|
|
|
286
314
|
```python
|
|
@@ -394,6 +422,7 @@ corekit/
|
|
|
394
422
|
|
|
395
423
|
api/ Application, lifespan, middleware, routers
|
|
396
424
|
docker/ notifications/ etl/
|
|
425
|
+
llm/ messages, tools, agentic loop (no GenerationClient)
|
|
397
426
|
|
|
398
427
|
events/ log_monitor/ built on the capabilities above
|
|
399
428
|
```
|