adjacency-agents 0.1.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,28 @@
1
+ """adjacency-agents public facade (§8.1)."""
2
+
3
+ from adjacency_agents.decorators import tool_node
4
+ from adjacency_agents.engine import DeterministicEngine
5
+ from adjacency_agents.models import (
6
+ EnrichedPointer,
7
+ FinalAnswer,
8
+ Message,
9
+ Observation,
10
+ ToolCall,
11
+ ToolPolicy,
12
+ UserContext,
13
+ )
14
+ from adjacency_agents.tracing import ExecutionTrace, TraceEvent
15
+
16
+ __all__ = [
17
+ "DeterministicEngine",
18
+ "EnrichedPointer",
19
+ "ExecutionTrace",
20
+ "FinalAnswer",
21
+ "Message",
22
+ "Observation",
23
+ "TraceEvent",
24
+ "ToolCall",
25
+ "ToolPolicy",
26
+ "UserContext",
27
+ "tool_node",
28
+ ]
@@ -0,0 +1,7 @@
1
+ """Provider adapters.
2
+
3
+ Adapters live in their own subpackage so that the core library has no
4
+ hard dependency on any specific LLM SDK. Each adapter module imports
5
+ its provider SDK lazily when needed, and accepts the SDK client object
6
+ directly so that tests can substitute lightweight fakes.
7
+ """
@@ -0,0 +1,216 @@
1
+ """Anthropic Messages API adapter — DDD §13.4, §18.3, Phase 6.
2
+
3
+ Translates between the engine's provider-agnostic protocol and the
4
+ Anthropic Messages API.
5
+
6
+ Design notes:
7
+
8
+ * No hard dependency on ``anthropic``. The user passes an instantiated
9
+ client (sync or async); the adapter only relies on the duck-typed
10
+ shape ``client.messages.create(**kwargs)``.
11
+ * Anthropic's tool format is ``{name, description, input_schema}`` with
12
+ no function envelope, and ``input_schema`` IS the JSON schema (no
13
+ nesting under ``parameters``). The adapter strips our internal
14
+ ``title``/``description`` keys before forwarding the schema body.
15
+ * ``role="system"`` messages are pulled out of the conversation and
16
+ promoted to the top-level ``system`` kwarg; multiple system messages
17
+ are concatenated with two newlines.
18
+ * ``role="tool"`` messages (produced by synthesis) are rewritten as
19
+ ``user`` messages prefixed with ``[tool: <name>]`` so the API does
20
+ not require a paired assistant ``tool_use`` block — which our
21
+ synthesis flow never produces.
22
+ * ``allow_tool_calls=False`` omits ``tools`` entirely. If the model
23
+ still returns a ``tool_use`` block, ``SynthesisError`` is raised.
24
+ * The Anthropic response is a list of content blocks. ``text`` blocks
25
+ are concatenated; a ``tool_use`` block returns ``ToolCall`` with the
26
+ already-decoded ``input`` dict.
27
+ * ``max_tokens`` is required by the API. Defaults to 1024 unless the
28
+ caller overrides it.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Sequence
34
+ from typing import Any
35
+
36
+ from adjacency_agents.errors import InvalidToolCallError, SynthesisError
37
+ from adjacency_agents.models import FinalAnswer, Message, ToolCall
38
+
39
+ __all__ = ["AnthropicClient", "AsyncAnthropicClient"]
40
+
41
+ _DEFAULT_MAX_TOKENS = 1024
42
+
43
+
44
+ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
45
+ """Project internal schemas onto Anthropic's ``{name, description,
46
+ input_schema}`` shape."""
47
+ out: list[dict[str, Any]] = []
48
+ for schema in tools:
49
+ input_schema: dict[str, Any] = {
50
+ k: v for k, v in schema.items() if k not in ("title", "description")
51
+ }
52
+ if "type" not in input_schema:
53
+ input_schema["type"] = "object"
54
+ entry: dict[str, Any] = {
55
+ "name": schema.get("title", ""),
56
+ "input_schema": input_schema,
57
+ }
58
+ description = schema.get("description")
59
+ if description:
60
+ entry["description"] = description
61
+ out.append(entry)
62
+ return out
63
+
64
+
65
+ def _split_messages(
66
+ messages: Sequence[Message],
67
+ ) -> tuple[str | None, list[dict[str, Any]]]:
68
+ """Pull ``role=system`` messages into a single system string and
69
+ convert the remaining messages to Anthropic's wire format."""
70
+ system_parts: list[str] = []
71
+ chat: list[dict[str, Any]] = []
72
+ for msg in messages:
73
+ if msg.role == "system":
74
+ system_parts.append(msg.content)
75
+ continue
76
+ if msg.role == "tool":
77
+ label = msg.name or "tool"
78
+ chat.append(
79
+ {
80
+ "role": "user",
81
+ "content": f"[tool: {label}] {msg.content}",
82
+ }
83
+ )
84
+ continue
85
+ chat.append({"role": msg.role, "content": msg.content})
86
+ system = "\n\n".join(system_parts) if system_parts else None
87
+ return system, chat
88
+
89
+
90
+ def _build_kwargs(
91
+ *,
92
+ model: str,
93
+ max_tokens: int,
94
+ messages: Sequence[Message],
95
+ tools: list[dict[str, Any]],
96
+ allow_tool_calls: bool,
97
+ extra: dict[str, Any] | None,
98
+ ) -> dict[str, Any]:
99
+ system, chat = _split_messages(messages)
100
+ payload: dict[str, Any] = {
101
+ "model": model,
102
+ "max_tokens": max_tokens,
103
+ "messages": chat,
104
+ }
105
+ if system is not None:
106
+ payload["system"] = system
107
+ if allow_tool_calls and tools:
108
+ payload["tools"] = _convert_tools(tools)
109
+ payload["tool_choice"] = {"type": "auto"}
110
+ if extra:
111
+ payload.update(extra)
112
+ return payload
113
+
114
+
115
+ def _parse_response(
116
+ response: Any, *, allow_tool_calls: bool
117
+ ) -> ToolCall | FinalAnswer | str:
118
+ blocks = list(getattr(response, "content", []) or [])
119
+ text_chunks: list[str] = []
120
+ for block in blocks:
121
+ btype = getattr(block, "type", None)
122
+ if btype == "tool_use":
123
+ if not allow_tool_calls:
124
+ raise SynthesisError(
125
+ "Anthropic returned a tool_use block during synthesis (§14.7.3)"
126
+ )
127
+ name = getattr(block, "name", None)
128
+ args = getattr(block, "input", None)
129
+ if not isinstance(name, str) or not name:
130
+ raise InvalidToolCallError("Anthropic tool_use block missing name")
131
+ if args is None:
132
+ args = {}
133
+ if not isinstance(args, dict):
134
+ raise InvalidToolCallError(
135
+ f"Anthropic tool_use input must be a JSON object, "
136
+ f"got {type(args).__name__}"
137
+ )
138
+ return ToolCall(name=name, kwargs=dict(args))
139
+ if btype == "text":
140
+ text_chunks.append(getattr(block, "text", "") or "")
141
+
142
+ if not text_chunks:
143
+ raise InvalidToolCallError(
144
+ "Anthropic response had no text and no tool_use blocks"
145
+ )
146
+ return FinalAnswer(content="".join(text_chunks))
147
+
148
+
149
+ class AnthropicClient:
150
+ """Synchronous adapter for ``anthropic.Anthropic``-shaped clients."""
151
+
152
+ def __init__(
153
+ self,
154
+ *,
155
+ client: Any,
156
+ model: str,
157
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
158
+ extra_create_kwargs: dict[str, Any] | None = None,
159
+ ) -> None:
160
+ self._client = client
161
+ self._model = model
162
+ self._max_tokens = max_tokens
163
+ self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
164
+
165
+ def complete(
166
+ self,
167
+ *,
168
+ messages: Sequence[Message],
169
+ tools: list[dict[str, Any]],
170
+ allow_tool_calls: bool = True,
171
+ ) -> ToolCall | FinalAnswer | str:
172
+ payload = _build_kwargs(
173
+ model=self._model,
174
+ max_tokens=self._max_tokens,
175
+ messages=messages,
176
+ tools=tools,
177
+ allow_tool_calls=allow_tool_calls,
178
+ extra=self._extra,
179
+ )
180
+ response = self._client.messages.create(**payload)
181
+ return _parse_response(response, allow_tool_calls=allow_tool_calls)
182
+
183
+
184
+ class AsyncAnthropicClient:
185
+ """Asynchronous adapter for ``anthropic.AsyncAnthropic``-shaped clients."""
186
+
187
+ def __init__(
188
+ self,
189
+ *,
190
+ client: Any,
191
+ model: str,
192
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
193
+ extra_create_kwargs: dict[str, Any] | None = None,
194
+ ) -> None:
195
+ self._client = client
196
+ self._model = model
197
+ self._max_tokens = max_tokens
198
+ self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
199
+
200
+ async def acomplete(
201
+ self,
202
+ *,
203
+ messages: Sequence[Message],
204
+ tools: list[dict[str, Any]],
205
+ allow_tool_calls: bool = True,
206
+ ) -> ToolCall | FinalAnswer | str:
207
+ payload = _build_kwargs(
208
+ model=self._model,
209
+ max_tokens=self._max_tokens,
210
+ messages=messages,
211
+ tools=tools,
212
+ allow_tool_calls=allow_tool_calls,
213
+ extra=self._extra,
214
+ )
215
+ response = await self._client.messages.create(**payload)
216
+ return _parse_response(response, allow_tool_calls=allow_tool_calls)
@@ -0,0 +1,220 @@
1
+ """Ollama adapter — DDD §13.4, §18.3, Phase 6.
2
+
3
+ Targets the official ``ollama`` Python SDK and any HTTP server that
4
+ mimics its ``client.chat(model=..., messages=..., tools=...)`` shape.
5
+
6
+ Design notes:
7
+
8
+ * No hard dependency on ``ollama``. The user passes any object exposing
9
+ ``client.chat(**kwargs)`` (sync) or an awaitable equivalent (async).
10
+ * Ollama uses the same tool envelope as OpenAI:
11
+ ``{"type": "function", "function": {"name", "description",
12
+ "parameters"}}``. Conversion mirrors the OpenAI adapter.
13
+ * The conversation roles supported are ``system``, ``user``,
14
+ ``assistant`` and ``tool``. Ollama keeps ``system`` inside the
15
+ messages list (unlike Anthropic). ``role="tool"`` messages produced
16
+ by synthesis are rewritten as ``user`` messages with a
17
+ ``[tool: <name>]`` prefix because most Ollama models will not accept
18
+ a bare tool role without a paired assistant ``tool_calls`` block.
19
+ * ``allow_tool_calls=False`` (synthesis) omits ``tools`` entirely. A
20
+ ``tool_calls`` payload returned anyway raises ``SynthesisError``.
21
+ * Tool arguments returned by the SDK may be either a Python dict
22
+ (current versions) or a JSON string (older clients / some local
23
+ servers). Both are accepted; JSON strings are ``json.loads``ed.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from collections.abc import Sequence
30
+ from typing import Any
31
+
32
+ from adjacency_agents.errors import InvalidToolCallError, SynthesisError
33
+ from adjacency_agents.models import FinalAnswer, Message, ToolCall
34
+
35
+ __all__ = ["OllamaClient", "AsyncOllamaClient"]
36
+
37
+
38
+ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
39
+ """Wrap internal schemas in the OpenAI-compatible function envelope
40
+ that Ollama accepts."""
41
+ out: list[dict[str, Any]] = []
42
+ for schema in tools:
43
+ params: dict[str, Any] = {
44
+ k: v for k, v in schema.items() if k not in ("title", "description")
45
+ }
46
+ if "type" not in params:
47
+ params["type"] = "object"
48
+ fn_def: dict[str, Any] = {
49
+ "name": schema.get("title", ""),
50
+ "parameters": params,
51
+ }
52
+ description = schema.get("description")
53
+ if description:
54
+ fn_def["description"] = description
55
+ out.append({"type": "function", "function": fn_def})
56
+ return out
57
+
58
+
59
+ def _convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
60
+ """Map our Message dataclasses to Ollama's wire format."""
61
+ out: list[dict[str, Any]] = []
62
+ for msg in messages:
63
+ if msg.role == "tool":
64
+ label = msg.name or "tool"
65
+ out.append(
66
+ {
67
+ "role": "user",
68
+ "content": f"[tool: {label}] {msg.content}",
69
+ }
70
+ )
71
+ continue
72
+ out.append({"role": msg.role, "content": msg.content})
73
+ return out
74
+
75
+
76
+ def _build_kwargs(
77
+ *,
78
+ model: str,
79
+ messages: Sequence[Message],
80
+ tools: list[dict[str, Any]],
81
+ allow_tool_calls: bool,
82
+ extra: dict[str, Any] | None,
83
+ ) -> dict[str, Any]:
84
+ payload: dict[str, Any] = {
85
+ "model": model,
86
+ "messages": _convert_messages(messages),
87
+ }
88
+ if allow_tool_calls and tools:
89
+ payload["tools"] = _convert_tools(tools)
90
+ if extra:
91
+ payload.update(extra)
92
+ return payload
93
+
94
+
95
+ def _decode_arguments(raw: Any, tool_name: str) -> dict[str, Any]:
96
+ """Ollama's ``arguments`` field is a dict in modern clients but may
97
+ be a JSON string from older servers. Accept both."""
98
+ if isinstance(raw, dict):
99
+ return raw
100
+ if isinstance(raw, str):
101
+ try:
102
+ decoded = json.loads(raw)
103
+ except json.JSONDecodeError as exc:
104
+ raise InvalidToolCallError(
105
+ f"Ollama returned non-JSON tool arguments for {tool_name!r}: {raw!r}"
106
+ ) from exc
107
+ if not isinstance(decoded, dict):
108
+ raise InvalidToolCallError(
109
+ f"Ollama tool arguments must be a JSON object, "
110
+ f"got {type(decoded).__name__}"
111
+ )
112
+ return decoded
113
+ if raw is None:
114
+ return {}
115
+ raise InvalidToolCallError(
116
+ f"Ollama tool arguments must be dict or JSON string, got {type(raw).__name__}"
117
+ )
118
+
119
+
120
+ def _get(obj: Any, key: str, default: Any = None) -> Any:
121
+ """Ollama responses come back as either typed objects or plain
122
+ dicts depending on the client version."""
123
+ if isinstance(obj, dict):
124
+ return obj.get(key, default)
125
+ return getattr(obj, key, default)
126
+
127
+
128
+ def _parse_response(
129
+ response: Any, *, allow_tool_calls: bool
130
+ ) -> ToolCall | FinalAnswer | str:
131
+ message = _get(response, "message")
132
+ if message is None:
133
+ raise InvalidToolCallError("Ollama response had no message")
134
+ tool_calls = _get(message, "tool_calls") or []
135
+ if tool_calls:
136
+ if not allow_tool_calls:
137
+ raise SynthesisError(
138
+ "Ollama returned tool_calls during synthesis (§14.7.3)"
139
+ )
140
+ first = tool_calls[0]
141
+ function = _get(first, "function")
142
+ if function is None:
143
+ raise InvalidToolCallError("Ollama tool_call missing function field")
144
+ name = _get(function, "name")
145
+ if not isinstance(name, str) or not name:
146
+ raise InvalidToolCallError("Ollama tool_call missing function name")
147
+ raw_args = _get(function, "arguments")
148
+ kwargs = _decode_arguments(raw_args, name)
149
+ return ToolCall(name=name, kwargs=kwargs)
150
+
151
+ content = _get(message, "content")
152
+ if not isinstance(content, str) or content == "":
153
+ raise InvalidToolCallError(
154
+ "Ollama response had neither tool_calls nor non-empty content"
155
+ )
156
+ return FinalAnswer(content=content)
157
+
158
+
159
+ class OllamaClient:
160
+ """Synchronous adapter for ``ollama.Client``-shaped clients."""
161
+
162
+ def __init__(
163
+ self,
164
+ *,
165
+ client: Any,
166
+ model: str,
167
+ extra_chat_kwargs: dict[str, Any] | None = None,
168
+ ) -> None:
169
+ self._client = client
170
+ self._model = model
171
+ self._extra = dict(extra_chat_kwargs) if extra_chat_kwargs else None
172
+
173
+ def complete(
174
+ self,
175
+ *,
176
+ messages: Sequence[Message],
177
+ tools: list[dict[str, Any]],
178
+ allow_tool_calls: bool = True,
179
+ ) -> ToolCall | FinalAnswer | str:
180
+ payload = _build_kwargs(
181
+ model=self._model,
182
+ messages=messages,
183
+ tools=tools,
184
+ allow_tool_calls=allow_tool_calls,
185
+ extra=self._extra,
186
+ )
187
+ response = self._client.chat(**payload)
188
+ return _parse_response(response, allow_tool_calls=allow_tool_calls)
189
+
190
+
191
+ class AsyncOllamaClient:
192
+ """Asynchronous adapter for ``ollama.AsyncClient``-shaped clients."""
193
+
194
+ def __init__(
195
+ self,
196
+ *,
197
+ client: Any,
198
+ model: str,
199
+ extra_chat_kwargs: dict[str, Any] | None = None,
200
+ ) -> None:
201
+ self._client = client
202
+ self._model = model
203
+ self._extra = dict(extra_chat_kwargs) if extra_chat_kwargs else None
204
+
205
+ async def acomplete(
206
+ self,
207
+ *,
208
+ messages: Sequence[Message],
209
+ tools: list[dict[str, Any]],
210
+ allow_tool_calls: bool = True,
211
+ ) -> ToolCall | FinalAnswer | str:
212
+ payload = _build_kwargs(
213
+ model=self._model,
214
+ messages=messages,
215
+ tools=tools,
216
+ allow_tool_calls=allow_tool_calls,
217
+ extra=self._extra,
218
+ )
219
+ response = await self._client.chat(**payload)
220
+ return _parse_response(response, allow_tool_calls=allow_tool_calls)
@@ -0,0 +1,199 @@
1
+ """OpenAI Chat Completions adapter — DDD §13.4, §18.3, Phase 6.
2
+
3
+ Translates between the engine's provider-agnostic protocol
4
+ (`LLMClient`/`AsyncLLMClient`) and the OpenAI Chat Completions API.
5
+
6
+ Design notes:
7
+
8
+ * No hard dependency on ``openai``. The user passes an instantiated
9
+ client (sync or async) and the adapter only relies on the duck-typed
10
+ shape ``client.chat.completions.create(**kwargs)``.
11
+ * The internal JSON schema produced by ``build_json_schema`` is wrapped
12
+ in OpenAI's ``{"type": "function", "function": {...}}`` envelope.
13
+ * When ``allow_tool_calls=False`` the adapter sends ``tool_choice="none"``
14
+ and **omits** the ``tools`` argument entirely. If the model still
15
+ returns a tool call, ``SynthesisError`` is raised.
16
+ * Messages with ``role="tool"`` (produced by the engine during
17
+ synthesis) cannot be sent as-is — OpenAI requires a paired assistant
18
+ ``tool_calls`` block, which never exists in our flow. They are
19
+ rewritten into ``system`` messages prefixed with ``[tool: <name>]``.
20
+ * Tool arguments returned by the model arrive as JSON strings; we
21
+ ``json.loads`` them. Any parse error raises ``InvalidToolCallError``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections.abc import Sequence
28
+ from typing import Any
29
+
30
+ from adjacency_agents.errors import InvalidToolCallError, SynthesisError
31
+ from adjacency_agents.models import FinalAnswer, Message, ToolCall
32
+
33
+ __all__ = ["OpenAIClient", "AsyncOpenAIClient"]
34
+
35
+
36
+ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
37
+ """Wrap our internal schemas in OpenAI's function-tool envelope."""
38
+ converted: list[dict[str, Any]] = []
39
+ for schema in tools:
40
+ params: dict[str, Any] = {
41
+ k: v for k, v in schema.items() if k not in ("title", "description")
42
+ }
43
+ # OpenAI expects parameters to be a JSON schema object.
44
+ if "type" not in params:
45
+ params["type"] = "object"
46
+ fn_def: dict[str, Any] = {
47
+ "name": schema.get("title", ""),
48
+ "parameters": params,
49
+ }
50
+ description = schema.get("description")
51
+ if description:
52
+ fn_def["description"] = description
53
+ converted.append({"type": "function", "function": fn_def})
54
+ return converted
55
+
56
+
57
+ def _convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
58
+ """Map our Message dataclasses to OpenAI's wire format.
59
+
60
+ ``role="tool"`` messages from synthesis are repackaged as ``system``
61
+ messages so OpenAI does not reject them (it would otherwise require
62
+ a matching assistant ``tool_calls`` block we never produced).
63
+ """
64
+ out: list[dict[str, Any]] = []
65
+ for msg in messages:
66
+ if msg.role == "tool":
67
+ label = msg.name or "tool"
68
+ out.append(
69
+ {
70
+ "role": "system",
71
+ "content": f"[tool: {label}] {msg.content}",
72
+ }
73
+ )
74
+ continue
75
+ out.append({"role": msg.role, "content": msg.content})
76
+ return out
77
+
78
+
79
+ def _build_kwargs(
80
+ *,
81
+ model: str,
82
+ messages: Sequence[Message],
83
+ tools: list[dict[str, Any]],
84
+ allow_tool_calls: bool,
85
+ extra: dict[str, Any] | None,
86
+ ) -> dict[str, Any]:
87
+ payload: dict[str, Any] = {
88
+ "model": model,
89
+ "messages": _convert_messages(messages),
90
+ }
91
+ if allow_tool_calls and tools:
92
+ payload["tools"] = _convert_tools(tools)
93
+ payload["tool_choice"] = "auto"
94
+ else:
95
+ # Synthesis: forbid tool calls outright.
96
+ payload["tool_choice"] = "none"
97
+ if extra:
98
+ payload.update(extra)
99
+ return payload
100
+
101
+
102
+ def _parse_response(
103
+ response: Any, *, allow_tool_calls: bool
104
+ ) -> ToolCall | FinalAnswer | str:
105
+ choices = getattr(response, "choices", None)
106
+ if not choices:
107
+ raise InvalidToolCallError("OpenAI response had no choices")
108
+ message = choices[0].message
109
+ tool_calls = getattr(message, "tool_calls", None) or []
110
+
111
+ if tool_calls:
112
+ if not allow_tool_calls:
113
+ raise SynthesisError(
114
+ "OpenAI returned a tool_call during synthesis (§14.7.3)"
115
+ )
116
+ first = tool_calls[0]
117
+ name = first.function.name
118
+ raw_args = first.function.arguments or "{}"
119
+ try:
120
+ kwargs = json.loads(raw_args)
121
+ except json.JSONDecodeError as exc:
122
+ raise InvalidToolCallError(
123
+ f"OpenAI returned non-JSON tool arguments for {name!r}: {raw_args!r}"
124
+ ) from exc
125
+ if not isinstance(kwargs, dict):
126
+ raise InvalidToolCallError(
127
+ f"OpenAI tool arguments must decode to a JSON object, "
128
+ f"got {type(kwargs).__name__}"
129
+ )
130
+ return ToolCall(name=name, kwargs=kwargs)
131
+
132
+ content = getattr(message, "content", None)
133
+ if content is None:
134
+ raise InvalidToolCallError("OpenAI response had neither content nor tool_calls")
135
+ return FinalAnswer(content=content)
136
+
137
+
138
+ class OpenAIClient:
139
+ """Synchronous adapter for ``openai.OpenAI``-shaped clients."""
140
+
141
+ def __init__(
142
+ self,
143
+ *,
144
+ client: Any,
145
+ model: str,
146
+ extra_create_kwargs: dict[str, Any] | None = None,
147
+ ) -> None:
148
+ self._client = client
149
+ self._model = model
150
+ self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
151
+
152
+ def complete(
153
+ self,
154
+ *,
155
+ messages: Sequence[Message],
156
+ tools: list[dict[str, Any]],
157
+ allow_tool_calls: bool = True,
158
+ ) -> ToolCall | FinalAnswer | str:
159
+ payload = _build_kwargs(
160
+ model=self._model,
161
+ messages=messages,
162
+ tools=tools,
163
+ allow_tool_calls=allow_tool_calls,
164
+ extra=self._extra,
165
+ )
166
+ response = self._client.chat.completions.create(**payload)
167
+ return _parse_response(response, allow_tool_calls=allow_tool_calls)
168
+
169
+
170
+ class AsyncOpenAIClient:
171
+ """Asynchronous adapter for ``openai.AsyncOpenAI``-shaped clients."""
172
+
173
+ def __init__(
174
+ self,
175
+ *,
176
+ client: Any,
177
+ model: str,
178
+ extra_create_kwargs: dict[str, Any] | None = None,
179
+ ) -> None:
180
+ self._client = client
181
+ self._model = model
182
+ self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
183
+
184
+ async def acomplete(
185
+ self,
186
+ *,
187
+ messages: Sequence[Message],
188
+ tools: list[dict[str, Any]],
189
+ allow_tool_calls: bool = True,
190
+ ) -> ToolCall | FinalAnswer | str:
191
+ payload = _build_kwargs(
192
+ model=self._model,
193
+ messages=messages,
194
+ tools=tools,
195
+ allow_tool_calls=allow_tool_calls,
196
+ extra=self._extra,
197
+ )
198
+ response = await self._client.chat.completions.create(**payload)
199
+ return _parse_response(response, allow_tool_calls=allow_tool_calls)