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.
@@ -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.timing()
12
- self.info("starting")
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
- The log line is unchanged. The return value is for a caller that
26
- wants the numbers without parsing that line.
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
- _time = time.perf_counter()
44
+ now = time.perf_counter()
23
45
  self.num += 1
24
46
  split = Split(
25
47
  num=self.num,
26
- total=self._round(_time - self.start),
27
- latest=self._round(_time - self.latest),
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 = _time
53
+ self.latest = now
32
54
  return split
@@ -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",
@@ -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.3.0
4
- Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, and ETL
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, and ETL scaffolding.
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.3.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
  ```