pdo-agent 2.0.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.
pdo/agent/messages.py ADDED
@@ -0,0 +1,87 @@
1
+ """Message and tool-call data structures.
2
+
3
+ These dataclasses are the in-memory representation of a conversation. They know
4
+ how to serialise themselves into the shape the OpenAI chat API expects, which
5
+ keeps that provider-specific detail out of the rest of the agent.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class ToolCall:
15
+ """A single tool invocation requested by the model.
16
+
17
+ ``arguments`` is the raw JSON string exactly as returned by the model; it is
18
+ only parsed at execution time so a malformed payload can be reported as a
19
+ tool error rather than crashing the loop.
20
+ """
21
+
22
+ id: str
23
+ name: str
24
+ arguments: str = "{}"
25
+
26
+ def to_openai(self) -> dict[str, Any]:
27
+ return {
28
+ "id": self.id,
29
+ "type": "function",
30
+ "function": {"name": self.name, "arguments": self.arguments},
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class Message:
36
+ """A single chat message.
37
+
38
+ A message may be a plain text turn, an assistant turn that requests tool
39
+ calls, or a tool-result turn. The optional fields capture those variants
40
+ without needing subclasses.
41
+ """
42
+
43
+ role: str # "system" | "user" | "assistant" | "tool"
44
+ # Either plain text or OpenAI multi-part content (e.g. text + image_url
45
+ # parts for vision models); lists are passed through to the API unchanged.
46
+ content: str | list[dict[str, Any]] | None = None
47
+ tool_calls: list[ToolCall] = field(default_factory=list)
48
+ tool_call_id: str | None = None # set on tool-result messages
49
+ name: str | None = None # tool name, set on tool-result messages
50
+
51
+ def to_openai(self) -> dict[str, Any]:
52
+ """Serialise to the dict shape expected by the OpenAI chat API."""
53
+ data: dict[str, Any] = {"role": self.role}
54
+
55
+ # An assistant message that only requests tool calls legitimately has
56
+ # ``content == None``; every other role needs a string content field.
57
+ if self.content is not None:
58
+ data["content"] = self.content
59
+ elif self.role != "assistant":
60
+ data["content"] = ""
61
+
62
+ if self.tool_calls:
63
+ data["tool_calls"] = [tc.to_openai() for tc in self.tool_calls]
64
+ if self.tool_call_id is not None:
65
+ data["tool_call_id"] = self.tool_call_id
66
+ if self.role == "tool" and self.name:
67
+ data["name"] = self.name
68
+ return data
69
+
70
+ # Convenience constructors keep call sites readable. ----------------------
71
+ @classmethod
72
+ def system(cls, content: str) -> Message:
73
+ return cls(role="system", content=content)
74
+
75
+ @classmethod
76
+ def user(cls, content: str) -> Message:
77
+ return cls(role="user", content=content)
78
+
79
+ @classmethod
80
+ def assistant(
81
+ cls, content: str | None = None, tool_calls: list[ToolCall] | None = None
82
+ ) -> Message:
83
+ return cls(role="assistant", content=content, tool_calls=tool_calls or [])
84
+
85
+ @classmethod
86
+ def tool(cls, content: str, tool_call_id: str, name: str) -> Message:
87
+ return cls(role="tool", content=content, tool_call_id=tool_call_id, name=name)
pdo/agent/planner.py ADDED
@@ -0,0 +1,38 @@
1
+ """Lightweight planner.
2
+
3
+ For multi-step requests the planner asks the model for a short, ordered list of
4
+ concrete steps. The plan is advisory: it is injected as context to keep the
5
+ agent focused, not enforced as a rigid script. This stays thin on purpose —
6
+ v1 does not build a planning framework.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from ..llm import LLMClient
13
+ from .messages import Message
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _PLAN_SYSTEM_PROMPT = (
18
+ "You are a planning assistant. Break the user's goal into a short, ordered "
19
+ "list of concrete, actionable steps (at most six). Reply with only the "
20
+ "numbered list — no preamble, no commentary."
21
+ )
22
+
23
+
24
+ class Planner:
25
+ """Turns a goal into a short list of steps using the LLM."""
26
+
27
+ def __init__(self, llm: LLMClient) -> None:
28
+ self._llm = llm
29
+
30
+ def plan(self, goal: str) -> list[str]:
31
+ """Return a list of step strings, or an empty list on failure."""
32
+ messages = [Message.system(_PLAN_SYSTEM_PROMPT), Message.user(goal)]
33
+ try:
34
+ response = self._llm.complete(messages, tools=None, stream=False)
35
+ except Exception: # noqa: BLE001 — planning is best-effort
36
+ logger.exception("Planning step failed; continuing without a plan")
37
+ return []
38
+ return [line.strip() for line in (response.content or "").splitlines() if line.strip()]
pdo/agent/reviewer.py ADDED
@@ -0,0 +1,25 @@
1
+ """Thin reviewer.
2
+
3
+ A final sanity check on the answer before it reaches the user. For v1 this just
4
+ guarantees a non-empty reply; it is the natural extension point for richer
5
+ checks later (e.g. verifying claimed actions actually ran).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Reviewer:
15
+ """Validates and, if necessary, repairs the agent's final answer."""
16
+
17
+ def review(self, user_input: str, answer: str) -> str:
18
+ text = (answer or "").strip()
19
+ if not text:
20
+ logger.warning("Empty final answer; substituting a fallback message")
21
+ return (
22
+ "I wasn't able to produce a response to that. "
23
+ "Could you rephrase or add a little more detail?"
24
+ )
25
+ return text
pdo/agent/router.py ADDED
@@ -0,0 +1,37 @@
1
+ """Thin router.
2
+
3
+ The real routing happens inside the model: it decides — via native tool
4
+ calling — whether any tool is needed. So this router always offers the tools and
5
+ just adds one cheap heuristic on top: should we spend a planning call before the
6
+ main loop? That is reserved for clearly multi-step task requests.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ # Verbs that typically signal a "do something" request rather than a chat.
14
+ _TASK_HINTS = re.compile(
15
+ r"\b(build|create|make|generate|write|implement|set\s?up|install|run|fix|"
16
+ r"refactor|delete|remove|deploy|configure|scaffold|add|migrate|convert)\b",
17
+ re.IGNORECASE,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class RouteDecision:
23
+ """The router's verdict for a single turn."""
24
+
25
+ expose_tools: bool
26
+ should_plan: bool
27
+
28
+
29
+ class Router:
30
+ """Decides whether tools are offered and whether to pre-plan."""
31
+
32
+ def route(self, user_input: str) -> RouteDecision:
33
+ text = user_input.strip()
34
+ # Plan only for substantial, task-shaped requests; trivial one-liners
35
+ # ("list files") don't benefit from a separate planning round-trip.
36
+ should_plan = bool(_TASK_HINTS.search(text)) and len(text.split()) >= 4
37
+ return RouteDecision(expose_tools=True, should_plan=should_plan)
pdo/api.py ADDED
@@ -0,0 +1,65 @@
1
+ """Embedding API: drive the PDO agent from Python.
2
+
3
+ Example::
4
+
5
+ from pdo import run_agent
6
+
7
+ answer = run_agent("list the markdown files here and summarise the README")
8
+
9
+ Configuration comes from the environment / ``.env`` exactly like the CLI
10
+ (``OPENAI_API_KEY``, ``OPENAI_BASE_URL``, ``OPENAI_MODEL``, …), with keyword
11
+ overrides for scripts. Each call uses an ephemeral memory, so it never touches
12
+ your interactive sessions.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ from .agent.core import Agent
20
+ from .agent.memory import MemoryStore
21
+ from .config import load_config
22
+ from .llm import LLMClient, OpenAIClient
23
+ from .tools.registry import get_registry
24
+
25
+
26
+ def run_agent(
27
+ prompt: str,
28
+ *,
29
+ model: str | None = None,
30
+ api_key: str | None = None,
31
+ base_url: str | None = None,
32
+ temperature: float | None = None,
33
+ planning: bool = False,
34
+ llm: LLMClient | None = None,
35
+ ) -> str:
36
+ """Run one task through the PDO agent and return its final answer.
37
+
38
+ Args:
39
+ prompt: the task or question.
40
+ model / api_key / base_url / temperature: override the env config.
41
+ planning: enable the pre-planning step for multi-step tasks.
42
+ llm: supply a custom :class:`~pdo.llm.LLMClient` (e.g. a mock in tests
43
+ or another provider implementation); overrides the other LLM args.
44
+ """
45
+ config = load_config()
46
+ if model:
47
+ config.openai_model = model
48
+ if api_key:
49
+ config.openai_api_key = api_key
50
+ if base_url is not None:
51
+ config.openai_base_url = base_url
52
+ if temperature is not None:
53
+ config.temperature = temperature
54
+
55
+ if llm is None:
56
+ llm = OpenAIClient(
57
+ api_key=config.openai_api_key,
58
+ model=config.openai_model,
59
+ temperature=config.temperature,
60
+ base_url=config.openai_base_url,
61
+ )
62
+
63
+ memory = MemoryStore(Path(tempfile.mkdtemp(prefix="pdo-api-")))
64
+ agent = Agent(config, llm, get_registry(), memory, planning=planning)
65
+ return agent.run_turn(prompt)
pdo/banner.py ADDED
@@ -0,0 +1,53 @@
1
+ """Pixel-art logo for the startup splash screen.
2
+
3
+ Each letter is a small bitmap (1 = filled pixel). Pixels are drawn as a pair of
4
+ block characters (``██``) so they look roughly square in a terminal, where
5
+ character cells are taller than they are wide.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ # 4-wide x 5-tall bitmaps for the letters we need.
10
+ _LETTERS: dict[str, list[str]] = {
11
+ "P": [
12
+ "1111",
13
+ "1001",
14
+ "1111",
15
+ "1000",
16
+ "1000",
17
+ ],
18
+ "D": [
19
+ "1110",
20
+ "1001",
21
+ "1001",
22
+ "1001",
23
+ "1110",
24
+ ],
25
+ "O": [
26
+ "1111",
27
+ "1001",
28
+ "1001",
29
+ "1001",
30
+ "1111",
31
+ ],
32
+ }
33
+
34
+ _ON = "██"
35
+ _OFF = " "
36
+ _ROWS = 5
37
+
38
+
39
+ def render_logo(word: str = "PDO", gap: str = " ") -> str:
40
+ """Render ``word`` as multi-line pixel-block art.
41
+
42
+ Raises:
43
+ KeyError: if a character in ``word`` has no defined bitmap.
44
+ """
45
+ lines = ["" for _ in range(_ROWS)]
46
+ last = len(word) - 1
47
+ for index, char in enumerate(word):
48
+ bitmap = _LETTERS[char.upper()]
49
+ for row in range(_ROWS):
50
+ lines[row] += "".join(_ON if pixel == "1" else _OFF for pixel in bitmap[row])
51
+ if index != last:
52
+ lines[row] += gap
53
+ return "\n".join(lines)
pdo/config.py ADDED
@@ -0,0 +1,151 @@
1
+ """Application configuration.
2
+
3
+ All configuration comes from environment variables (optionally loaded from a
4
+ ``.env`` file) and is validated at startup with ``pydantic``. We deliberately do
5
+ not depend on ``pydantic-settings`` to keep the dependency surface small; reading
6
+ the environment by hand is trivial and keeps the failure messages friendly.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from dotenv import load_dotenv
14
+ from pydantic import BaseModel, Field, ValidationError, field_validator
15
+
16
+
17
+ class ConfigError(RuntimeError):
18
+ """Raised when configuration is missing or invalid.
19
+
20
+ The message is intended to be shown directly to the user, so it must be
21
+ human-friendly rather than a stack trace.
22
+ """
23
+
24
+
25
+ class Config(BaseModel):
26
+ """Validated runtime configuration."""
27
+
28
+ openai_api_key: str = Field(..., min_length=1)
29
+ openai_model: str = Field(default="gpt-4.1-mini", min_length=1)
30
+ # Optional override of the API endpoint. Set this to use an OpenAI-compatible
31
+ # provider such as OpenRouter, a local model server, etc. None = OpenAI.
32
+ openai_base_url: str | None = Field(default=None)
33
+ temperature: float = Field(default=0.2, ge=0.0, le=2.0)
34
+ # Render assistant replies as Markdown (vs. raw token streaming).
35
+ render_markdown: bool = Field(default=True)
36
+ # Accent color theme name (see pdo.theme.THEMES).
37
+ theme: str = Field(default="cyan")
38
+ # Per-tool permission policy: tools that are blocked, or require confirmation.
39
+ deny_tools: list[str] = Field(default_factory=list)
40
+ ask_tools: list[str] = Field(default_factory=list)
41
+
42
+ @field_validator("openai_api_key", "openai_model")
43
+ @classmethod
44
+ def _strip(cls, value: str) -> str:
45
+ return (value or "").strip()
46
+
47
+
48
+ def load_config() -> Config:
49
+ """Load and validate configuration from the environment.
50
+
51
+ Raises:
52
+ ConfigError: if required values are missing or invalid. The message is
53
+ safe to print directly to the terminal.
54
+ """
55
+ load_dotenv() # no-op if there is no .env file
56
+
57
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
58
+ if not api_key:
59
+ raise ConfigError(
60
+ "OPENAI_API_KEY is not set.\n\n"
61
+ "Set it before running PDO, for example:\n"
62
+ " export OPENAI_API_KEY=sk-...\n\n"
63
+ "or copy .env.example to .env and fill it in."
64
+ )
65
+
66
+ raw_temperature = os.getenv("TEMPERATURE", "0.2") or "0.2"
67
+ try:
68
+ temperature = float(raw_temperature)
69
+ except ValueError as exc:
70
+ raise ConfigError(
71
+ f"TEMPERATURE must be a number between 0 and 2 (got {raw_temperature!r})."
72
+ ) from exc
73
+
74
+ markdown_raw = os.getenv("PDO_MARKDOWN", "1").strip().lower()
75
+ render_markdown = markdown_raw not in ("0", "false", "no", "off")
76
+
77
+ def _csv(name: str) -> list[str]:
78
+ return [item.strip() for item in os.getenv(name, "").split(",") if item.strip()]
79
+
80
+ try:
81
+ return Config(
82
+ openai_api_key=api_key,
83
+ openai_model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
84
+ openai_base_url=os.getenv("OPENAI_BASE_URL", "").strip() or None,
85
+ temperature=temperature,
86
+ render_markdown=render_markdown,
87
+ theme=os.getenv("PDO_THEME", "cyan").strip() or "cyan",
88
+ deny_tools=_csv("PDO_DENY_TOOLS"),
89
+ ask_tools=_csv("PDO_ASK_TOOLS"),
90
+ )
91
+ except ValidationError as exc:
92
+ # Surface the first, most relevant validation problem in plain language.
93
+ first = exc.errors()[0]
94
+ field = ".".join(str(p) for p in first["loc"])
95
+ raise ConfigError(f"Invalid configuration for {field}: {first['msg']}.") from exc
96
+
97
+
98
+ # --- Filesystem locations -------------------------------------------------- #
99
+ #
100
+ # Runtime state (the JSON memory store and rotating logs) lives under a single
101
+ # "home" directory. By default that is the installed package directory so a
102
+ # freshly cloned repo "just works"; set PDO_HOME to relocate it (e.g. ~/.pdo).
103
+
104
+
105
+ def get_home_dir() -> Path:
106
+ """Return the base directory for PDO runtime state."""
107
+ env = os.getenv("PDO_HOME")
108
+ if env:
109
+ return Path(env).expanduser()
110
+ return Path(__file__).resolve().parent
111
+
112
+
113
+ def get_data_dir() -> Path:
114
+ """Return (creating if needed) the directory holding JSON state files."""
115
+ path = get_home_dir() / "data"
116
+ path.mkdir(parents=True, exist_ok=True)
117
+ return path
118
+
119
+
120
+ def get_logs_dir() -> Path:
121
+ """Return (creating if needed) the directory holding log files."""
122
+ path = get_home_dir() / "logs"
123
+ path.mkdir(parents=True, exist_ok=True)
124
+ return path
125
+
126
+
127
+ def get_plugins_dir() -> Path:
128
+ """Return (creating if needed) the directory scanned for user tool plugins.
129
+
130
+ Drop a ``.py`` file defining a ``Tool`` subclass here and PDO loads it on
131
+ startup. Defaults to ``<home>/plugins`` (override the home with ``PDO_HOME``).
132
+ """
133
+ path = get_home_dir() / "plugins"
134
+ path.mkdir(parents=True, exist_ok=True)
135
+ return path
136
+
137
+
138
+ def get_mcp_config_path() -> Path:
139
+ """Return the path to the MCP servers config file (``<home>/mcp.json``)."""
140
+ return get_home_dir() / "mcp.json"
141
+
142
+
143
+ def get_skills_dir() -> Path:
144
+ """Return (creating if needed) the directory scanned for user skills.
145
+
146
+ Each ``.md`` file becomes a reusable slash command (e.g. ``review.md`` →
147
+ ``/review``). Defaults to ``<home>/skills``.
148
+ """
149
+ path = get_home_dir() / "skills"
150
+ path.mkdir(parents=True, exist_ok=True)
151
+ return path
pdo/llm.py ADDED
@@ -0,0 +1,211 @@
1
+ """LLM abstraction.
2
+
3
+ The core agent depends only on the :class:`LLMClient` interface, never on a
4
+ concrete provider. ``OpenAIClient`` is the single implementation shipped in v1;
5
+ adding another provider (Anthropic, a local model, etc.) means writing a new
6
+ class here without touching the agent.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from .agent.messages import Message, ToolCall
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class LLMError(RuntimeError):
22
+ """Raised when a request to the language model fails."""
23
+
24
+
25
+ def _looks_like_tools_unsupported(exc: Exception) -> bool:
26
+ """Heuristic: does this provider error mean the model can't use tools?
27
+
28
+ Covers messages like "<model> does not support tools" returned by Ollama and
29
+ similar OpenAI-compatible endpoints for tool-less models.
30
+ """
31
+ message = str(exc).lower()
32
+ return "tool" in message and ("support" in message or "not supported" in message)
33
+
34
+
35
+ @dataclass
36
+ class LLMResponse:
37
+ """A normalised model response: free-text content and/or tool calls."""
38
+
39
+ content: str = ""
40
+ tool_calls: list[ToolCall] = field(default_factory=list)
41
+ # Token usage for this call, if the provider reported it.
42
+ usage: dict[str, int] | None = None
43
+
44
+
45
+ class LLMClient(ABC):
46
+ """Provider-agnostic chat interface used by the agent."""
47
+
48
+ @abstractmethod
49
+ def complete(
50
+ self,
51
+ messages: Sequence[Message],
52
+ tools: list[dict[str, Any]] | None = None,
53
+ *,
54
+ stream: bool = False,
55
+ on_token: Callable[[str], None] | None = None,
56
+ ) -> LLMResponse:
57
+ """Send ``messages`` to the model and return its response.
58
+
59
+ Args:
60
+ messages: the conversation so far.
61
+ tools: JSON tool schemas the model may call, or ``None`` to disable
62
+ tool calling for this request.
63
+ stream: when ``True``, emit content tokens via ``on_token`` as they
64
+ arrive (the full response is still returned at the end).
65
+ on_token: callback invoked with each content token while streaming.
66
+ """
67
+
68
+
69
+ class OpenAIClient(LLMClient):
70
+ """OpenAI implementation of :class:`LLMClient` using native tool calling."""
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: str,
75
+ model: str,
76
+ temperature: float = 0.2,
77
+ base_url: str | None = None,
78
+ client: Any | None = None,
79
+ ) -> None:
80
+ # ``client`` is injectable so tests can pass a fake without importing
81
+ # the SDK or hitting the network. ``base_url`` targets any OpenAI-
82
+ # compatible endpoint (OpenRouter, a local server, …); None = OpenAI.
83
+ if client is None:
84
+ from openai import OpenAI
85
+
86
+ client = OpenAI(api_key=api_key, base_url=base_url)
87
+ self._client = client
88
+ self._model = model
89
+ self._temperature = temperature
90
+ # Some models (e.g. small local Ollama models) reject tool schemas. Once
91
+ # we learn that, we stop sending tools to avoid repeated failed requests.
92
+ self._supports_tools = True
93
+
94
+ def complete(
95
+ self,
96
+ messages: Sequence[Message],
97
+ tools: list[dict[str, Any]] | None = None,
98
+ *,
99
+ stream: bool = False,
100
+ on_token: Callable[[str], None] | None = None,
101
+ ) -> LLMResponse:
102
+ kwargs: dict[str, Any] = {
103
+ "model": self._model,
104
+ "messages": [m.to_openai() for m in messages],
105
+ "temperature": self._temperature,
106
+ }
107
+ if tools and self._supports_tools:
108
+ kwargs["tools"] = tools
109
+ kwargs["tool_choice"] = "auto"
110
+
111
+ try:
112
+ return self._request(kwargs, stream, on_token)
113
+ except Exception as exc: # noqa: BLE001 — normalise every provider error
114
+ # Degrade gracefully when the model doesn't support tool calling:
115
+ # drop the tools and retry once as a plain chat request.
116
+ if "tools" in kwargs and _looks_like_tools_unsupported(exc):
117
+ logger.warning("Model %r does not support tools; retrying without them", self._model)
118
+ self._supports_tools = False
119
+ kwargs.pop("tools", None)
120
+ kwargs.pop("tool_choice", None)
121
+ try:
122
+ return self._request(kwargs, stream, on_token)
123
+ except Exception as retry_exc: # noqa: BLE001
124
+ logger.exception("LLM request failed (no-tools retry)")
125
+ raise LLMError(f"LLM request failed: {retry_exc}") from retry_exc
126
+ logger.exception("LLM request failed")
127
+ raise LLMError(f"LLM request failed: {exc}") from exc
128
+
129
+ def _request(
130
+ self, kwargs: dict[str, Any], stream: bool, on_token: Callable[[str], None] | None
131
+ ) -> LLMResponse:
132
+ if stream:
133
+ return self._complete_stream(kwargs, on_token)
134
+ return self._complete_once(kwargs)
135
+
136
+ def _complete_once(self, kwargs: dict[str, Any]) -> LLMResponse:
137
+ response = self._client.chat.completions.create(**kwargs)
138
+ message = response.choices[0].message
139
+ tool_calls = [
140
+ ToolCall(id=tc.id, name=tc.function.name, arguments=tc.function.arguments or "{}")
141
+ for tc in (message.tool_calls or [])
142
+ ]
143
+ return LLMResponse(
144
+ content=message.content or "",
145
+ tool_calls=tool_calls,
146
+ usage=_usage_to_dict(getattr(response, "usage", None)),
147
+ )
148
+
149
+ def _complete_stream(
150
+ self, kwargs: dict[str, Any], on_token: Callable[[str], None] | None
151
+ ) -> LLMResponse:
152
+ # include_usage asks the API to emit a final usage chunk while streaming;
153
+ # some OpenAI-compatible endpoints reject it, so fall back without it.
154
+ try:
155
+ stream = self._client.chat.completions.create(
156
+ **kwargs, stream=True, stream_options={"include_usage": True}
157
+ )
158
+ except TypeError:
159
+ stream = self._client.chat.completions.create(**kwargs, stream=True)
160
+
161
+ content_parts: list[str] = []
162
+ # Tool-call deltas arrive in fragments keyed by ``index``; accumulate
163
+ # them until the stream completes.
164
+ tool_acc: dict[int, dict[str, str]] = {}
165
+ usage = None
166
+
167
+ for chunk in stream:
168
+ if getattr(chunk, "usage", None):
169
+ usage = chunk.usage
170
+ if not chunk.choices:
171
+ continue
172
+ delta = chunk.choices[0].delta
173
+
174
+ text = getattr(delta, "content", None)
175
+ if text:
176
+ content_parts.append(text)
177
+ if on_token:
178
+ on_token(text)
179
+
180
+ for tc in getattr(delta, "tool_calls", None) or []:
181
+ slot = tool_acc.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
182
+ if tc.id:
183
+ slot["id"] = tc.id
184
+ if tc.function and tc.function.name:
185
+ slot["name"] = tc.function.name
186
+ if tc.function and tc.function.arguments:
187
+ slot["arguments"] += tc.function.arguments
188
+
189
+ tool_calls = [
190
+ ToolCall(
191
+ id=slot["id"] or f"call_{index}",
192
+ name=slot["name"],
193
+ arguments=slot["arguments"] or "{}",
194
+ )
195
+ for index, slot in sorted(tool_acc.items())
196
+ if slot["name"]
197
+ ]
198
+ return LLMResponse(
199
+ content="".join(content_parts), tool_calls=tool_calls, usage=_usage_to_dict(usage)
200
+ )
201
+
202
+
203
+ def _usage_to_dict(usage: Any) -> dict[str, int] | None:
204
+ """Normalise a provider usage object into a plain dict, if present."""
205
+ if usage is None:
206
+ return None
207
+ return {
208
+ "prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0,
209
+ "completion_tokens": getattr(usage, "completion_tokens", 0) or 0,
210
+ "total_tokens": getattr(usage, "total_tokens", 0) or 0,
211
+ }