padwan-ai 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. padwan_ai-0.11.0/PKG-INFO +15 -0
  2. padwan_ai-0.11.0/padwan_ai/__init__.py +197 -0
  3. padwan_ai-0.11.0/padwan_ai/__main__.py +127 -0
  4. padwan_ai-0.11.0/padwan_ai/_base.py +338 -0
  5. padwan_ai-0.11.0/padwan_ai/_deprecation.py +50 -0
  6. padwan_ai-0.11.0/padwan_ai/_json.py +16 -0
  7. padwan_ai-0.11.0/padwan_ai/_ws.py +79 -0
  8. padwan_ai-0.11.0/padwan_ai/agent.py +713 -0
  9. padwan_ai-0.11.0/padwan_ai/anthropic/__init__.py +30 -0
  10. padwan_ai-0.11.0/padwan_ai/anthropic/audio.py +8 -0
  11. padwan_ai-0.11.0/padwan_ai/anthropic/client.py +375 -0
  12. padwan_ai-0.11.0/padwan_ai/anthropic/compat.py +281 -0
  13. padwan_ai-0.11.0/padwan_ai/anthropic/events.py +280 -0
  14. padwan_ai-0.11.0/padwan_ai/anthropic/models.py +123 -0
  15. padwan_ai-0.11.0/padwan_ai/anthropic/tools.py +83 -0
  16. padwan_ai-0.11.0/padwan_ai/anthropic/vision.py +6 -0
  17. padwan_ai-0.11.0/padwan_ai/audio.py +34 -0
  18. padwan_ai-0.11.0/padwan_ai/client.py +298 -0
  19. padwan_ai-0.11.0/padwan_ai/content.py +148 -0
  20. padwan_ai-0.11.0/padwan_ai/conversation.py +177 -0
  21. padwan_ai-0.11.0/padwan_ai/errors.py +52 -0
  22. padwan_ai-0.11.0/padwan_ai/gemini/__init__.py +52 -0
  23. padwan_ai-0.11.0/padwan_ai/gemini/audio.py +14 -0
  24. padwan_ai-0.11.0/padwan_ai/gemini/batch.py +113 -0
  25. padwan_ai-0.11.0/padwan_ai/gemini/client.py +636 -0
  26. padwan_ai-0.11.0/padwan_ai/gemini/models.py +254 -0
  27. padwan_ai-0.11.0/padwan_ai/gemini/realtime.py +219 -0
  28. padwan_ai-0.11.0/padwan_ai/gemini/tools.py +115 -0
  29. padwan_ai-0.11.0/padwan_ai/gemini/vision.py +6 -0
  30. padwan_ai-0.11.0/padwan_ai/grok/__init__.py +38 -0
  31. padwan_ai-0.11.0/padwan_ai/grok/audio.py +8 -0
  32. padwan_ai-0.11.0/padwan_ai/grok/batch.py +128 -0
  33. padwan_ai-0.11.0/padwan_ai/grok/client.py +209 -0
  34. padwan_ai-0.11.0/padwan_ai/grok/realtime.py +43 -0
  35. padwan_ai-0.11.0/padwan_ai/grok/types.py +119 -0
  36. padwan_ai-0.11.0/padwan_ai/grok/vision.py +6 -0
  37. padwan_ai-0.11.0/padwan_ai/langfuse.py +269 -0
  38. padwan_ai-0.11.0/padwan_ai/logs.py +3 -0
  39. padwan_ai-0.11.0/padwan_ai/mcp.py +854 -0
  40. padwan_ai-0.11.0/padwan_ai/mistral/__init__.py +33 -0
  41. padwan_ai-0.11.0/padwan_ai/mistral/_deprecations.py +4 -0
  42. padwan_ai-0.11.0/padwan_ai/mistral/audio.py +15 -0
  43. padwan_ai-0.11.0/padwan_ai/mistral/client.py +219 -0
  44. padwan_ai-0.11.0/padwan_ai/mistral/types.py +413 -0
  45. padwan_ai-0.11.0/padwan_ai/mistral/vision.py +6 -0
  46. padwan_ai-0.11.0/padwan_ai/models.py +70 -0
  47. padwan_ai-0.11.0/padwan_ai/openai/__init__.py +55 -0
  48. padwan_ai-0.11.0/padwan_ai/openai/audio.py +14 -0
  49. padwan_ai-0.11.0/padwan_ai/openai/batch.py +126 -0
  50. padwan_ai-0.11.0/padwan_ai/openai/client.py +556 -0
  51. padwan_ai-0.11.0/padwan_ai/openai/realtime.py +235 -0
  52. padwan_ai-0.11.0/padwan_ai/openai/tools.py +91 -0
  53. padwan_ai-0.11.0/padwan_ai/openai/types.py +1164 -0
  54. padwan_ai-0.11.0/padwan_ai/openai/vision.py +18 -0
  55. padwan_ai-0.11.0/padwan_ai/otel.py +1557 -0
  56. padwan_ai-0.11.0/padwan_ai/testing.py +142 -0
  57. padwan_ai-0.11.0/padwan_ai/tools.py +248 -0
  58. padwan_ai-0.11.0/padwan_ai/typesafe/__init__.py +37 -0
  59. padwan_ai-0.11.0/padwan_ai/typesafe/client.py +159 -0
  60. padwan_ai-0.11.0/padwan_ai/typesafe/models.py +81 -0
  61. padwan_ai-0.11.0/padwan_ai/vision.py +30 -0
  62. padwan_ai-0.11.0/pyproject.toml +105 -0
  63. padwan_ai-0.11.0/pyproject.toml.orig +101 -0
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.3
2
+ Name: padwan-ai
3
+ Version: 0.11.0
4
+ Author-email: julien.brayere@polarsen.io
5
+ Requires-Dist: niquests>=3.21.0
6
+ Requires-Dist: qh3>=1.7.1
7
+ Requires-Dist: urllib3-future>=2.24.904
8
+ Requires-Dist: langfuse>=4.9 ; extra == 'langfuse'
9
+ Requires-Dist: opentelemetry-api>=1.44 ; extra == 'langfuse'
10
+ Requires-Dist: opentelemetry-api>=1.44 ; extra == 'otel'
11
+ Requires-Dist: niquests[ws]>=3.21.0 ; extra == 'realtime'
12
+ Requires-Python: >=3.13
13
+ Provides-Extra: langfuse
14
+ Provides-Extra: otel
15
+ Provides-Extra: realtime
@@ -0,0 +1,197 @@
1
+ # Python 3.15 defers these imports; older runtimes ignore the declaration.
2
+ __lazy_modules__ = frozenset(
3
+ {
4
+ "padwan_ai._base",
5
+ "padwan_ai._deprecation",
6
+ "padwan_ai.agent",
7
+ "padwan_ai.anthropic",
8
+ "padwan_ai.audio",
9
+ "padwan_ai.client",
10
+ "padwan_ai.content",
11
+ "padwan_ai.conversation",
12
+ "padwan_ai.errors",
13
+ "padwan_ai.gemini",
14
+ "padwan_ai.grok",
15
+ "padwan_ai.mcp",
16
+ "padwan_ai.mistral",
17
+ "padwan_ai.models",
18
+ "padwan_ai.openai",
19
+ "padwan_ai.typesafe",
20
+ "padwan_ai.vision",
21
+ }
22
+ )
23
+
24
+ from importlib.metadata import version as _pkg_version
25
+
26
+ from ._base import ChatStream, LLMClientBase, OnThought, RealtimeClientBase
27
+ from ._deprecation import ModelDeprecationWarning
28
+ from .agent import (
29
+ AgentOutput,
30
+ AgentSession,
31
+ ConversationStore,
32
+ OnMcpConnect,
33
+ ToolCallContext,
34
+ )
35
+ from .anthropic import (
36
+ ANTHROPIC_MODELS,
37
+ AnthropicClient,
38
+ AnthropicModel,
39
+ is_anthropic_model,
40
+ )
41
+ from .audio import supports_audio
42
+ from .client import LLMClient, RealtimeClient
43
+ from .content import (
44
+ AudioFormat,
45
+ ContentAudioPart,
46
+ ContentImagePart,
47
+ ContentPart,
48
+ ContentTextPart,
49
+ audio_part,
50
+ content_parts,
51
+ image_part,
52
+ text_file_part,
53
+ text_part,
54
+ )
55
+ from .conversation import (
56
+ AssistantToolMessage,
57
+ ChatMessage,
58
+ ConversationSnapshot,
59
+ ConversationState,
60
+ Message,
61
+ ToolResultMessage,
62
+ )
63
+ from .errors import LLMError, OutputError, Provider
64
+ from .gemini import (
65
+ GEMINI_MODELS,
66
+ GeminiClient,
67
+ GeminiModel,
68
+ GeminiRealtimeClient,
69
+ GeminiRealtimeConnection,
70
+ is_gemini_model,
71
+ )
72
+ from .grok import GROK_MODELS, GrokClient, GrokModel, GrokRealtimeClient, is_grok_model
73
+ from .mcp import McpStdio, McpStreamable, McpTool, McpTransport, OnAuth, ProgressEvent
74
+ from .mistral import MISTRAL_MODELS, MistralClient, MistralModel, is_mistral_model
75
+ from .models import (
76
+ ChatResponse,
77
+ FinishReason,
78
+ ToolCall,
79
+ ToolCallFunction,
80
+ ToolDefinition,
81
+ UsageToken,
82
+ )
83
+ from .openai import (
84
+ OPENAI_CHAT_MODELS,
85
+ OPENAI_MODELS,
86
+ OpenAIClient,
87
+ OpenAIModel,
88
+ OpenAIRealtimeClient,
89
+ RealtimeConnection,
90
+ RealtimeServerEvent,
91
+ is_openai_model,
92
+ )
93
+ from .typesafe import (
94
+ TYPESAFE_MODELS,
95
+ ChoiceAnswer,
96
+ ChoiceQuestion,
97
+ NoulAnswer,
98
+ NoulCriteria,
99
+ NoulQuestion,
100
+ ScoreAnswer,
101
+ ScoreQuestion,
102
+ SystemOneResponse,
103
+ TypeSafeClient,
104
+ TypeSafeModel,
105
+ TypeSafeUsage,
106
+ )
107
+ from .vision import supports_vision
108
+
109
+ __all__ = (
110
+ "ANTHROPIC_MODELS",
111
+ "GEMINI_MODELS",
112
+ "GROK_MODELS",
113
+ "MISTRAL_MODELS",
114
+ "OPENAI_CHAT_MODELS",
115
+ "OPENAI_MODELS",
116
+ "TYPESAFE_MODELS",
117
+ "AgentOutput",
118
+ "AgentSession",
119
+ "AnthropicClient",
120
+ "AnthropicModel",
121
+ "AssistantToolMessage",
122
+ "AudioFormat",
123
+ "ChatMessage",
124
+ "ChatResponse",
125
+ "ChatStream",
126
+ "ChoiceAnswer",
127
+ "ChoiceQuestion",
128
+ "ContentAudioPart",
129
+ "ContentImagePart",
130
+ "ContentPart",
131
+ "ContentTextPart",
132
+ "ConversationSnapshot",
133
+ "ConversationState",
134
+ "ConversationStore",
135
+ "FinishReason",
136
+ "GeminiClient",
137
+ "GeminiModel",
138
+ "GeminiRealtimeClient",
139
+ "GeminiRealtimeConnection",
140
+ "GrokClient",
141
+ "GrokModel",
142
+ "GrokRealtimeClient",
143
+ "LLMClient",
144
+ "LLMClientBase",
145
+ "LLMError",
146
+ "McpStdio",
147
+ "McpStreamable",
148
+ "McpTool",
149
+ "McpTransport",
150
+ "Message",
151
+ "MistralClient",
152
+ "MistralModel",
153
+ "ModelDeprecationWarning",
154
+ "OnAuth",
155
+ "OnMcpConnect",
156
+ "OnThought",
157
+ "OpenAIClient",
158
+ "OpenAIModel",
159
+ "OpenAIRealtimeClient",
160
+ "NoulAnswer",
161
+ "NoulCriteria",
162
+ "NoulQuestion",
163
+ "OutputError",
164
+ "ProgressEvent",
165
+ "Provider",
166
+ "RealtimeClient",
167
+ "RealtimeClientBase",
168
+ "RealtimeConnection",
169
+ "RealtimeServerEvent",
170
+ "ScoreAnswer",
171
+ "ScoreQuestion",
172
+ "SystemOneResponse",
173
+ "ToolCall",
174
+ "ToolCallContext",
175
+ "ToolCallFunction",
176
+ "ToolDefinition",
177
+ "ToolResultMessage",
178
+ "TypeSafeClient",
179
+ "TypeSafeModel",
180
+ "TypeSafeUsage",
181
+ "UsageToken",
182
+ "__version__",
183
+ "audio_part",
184
+ "content_parts",
185
+ "image_part",
186
+ "is_anthropic_model",
187
+ "is_gemini_model",
188
+ "is_grok_model",
189
+ "is_mistral_model",
190
+ "is_openai_model",
191
+ "supports_audio",
192
+ "supports_vision",
193
+ "text_file_part",
194
+ "text_part",
195
+ )
196
+
197
+ __version__: str = _pkg_version("padwan-ai")
@@ -0,0 +1,127 @@
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+ from ._base import OnThought
8
+ from .client import LLMClient
9
+ from .conversation import Message
10
+
11
+
12
+ def _make_thought_streamer() -> OnThought:
13
+ def on_thought(chunk: str) -> None:
14
+ sys.stderr.write(chunk)
15
+ sys.stderr.flush()
16
+
17
+ return on_thought
18
+
19
+
20
+ async def _run(
21
+ model: str,
22
+ prompt: str,
23
+ base_url: str | None = None,
24
+ extra_params: dict[str, Any] | None = None,
25
+ stream_thinking: bool = False,
26
+ debug: bool = False,
27
+ pretty: bool = False,
28
+ ) -> None:
29
+ on_thought: OnThought | None = _make_thought_streamer() if stream_thinking else None
30
+ client = LLMClient(model=model, base_url=base_url, on_thought=on_thought)
31
+ messages: list[Message] = [Message(role="user", content=prompt)]
32
+ async with client:
33
+ if debug:
34
+ body: dict[str, Any] = {
35
+ "model": model,
36
+ "messages": list(messages),
37
+ "temperature": client.temperature,
38
+ "stream_options": {"include_usage": True},
39
+ }
40
+ if extra_params:
41
+ body.update(extra_params)
42
+ async for raw in client.stream(body):
43
+ sys.stderr.write(json.dumps(raw, indent=2 if pretty else None) + "\n")
44
+ sys.stderr.flush()
45
+ if choices := raw.get("choices"):
46
+ delta = choices[0].get("delta") or {}
47
+ if text := delta.get("content"):
48
+ sys.stdout.write(text)
49
+ sys.stdout.flush()
50
+ else:
51
+ stream = client.stream_chat(messages, extra_params=extra_params)
52
+ async for chunk in stream:
53
+ sys.stdout.write(chunk)
54
+ sys.stdout.flush()
55
+ if reason := getattr(stream, "finish_reason", None):
56
+ if reason != "stop":
57
+ sys.stderr.write(f"\n[finish_reason: {reason}]\n")
58
+ sys.stderr.flush()
59
+ print()
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser(
64
+ prog="padwan-ai",
65
+ description="One-shot LLM query",
66
+ )
67
+ parser.add_argument("prompt", help="Prompt to send")
68
+ parser.add_argument("-m", "--model", default="gpt-4o-mini", help="Model to use")
69
+ parser.add_argument(
70
+ "--base-url", default=None, help="Custom base URL for the LLM provider"
71
+ )
72
+ parser.add_argument(
73
+ "--extra-params",
74
+ default=None,
75
+ metavar="JSON",
76
+ help="Extra JSON parameters merged into the request body (e.g. '{\"temperature\": 0}')",
77
+ )
78
+ parser.add_argument(
79
+ "--stream-thinking",
80
+ action="store_true",
81
+ default=False,
82
+ help="Stream model reasoning/thinking tokens to stderr",
83
+ )
84
+ parser.add_argument(
85
+ "--debug",
86
+ action="store_true",
87
+ default=False,
88
+ help="Print raw SSE chunks as JSON to stderr",
89
+ )
90
+ parser.add_argument(
91
+ "--pretty",
92
+ action="store_true",
93
+ default=False,
94
+ help="Pretty-print --debug JSON output (implies --debug)",
95
+ )
96
+ args = parser.parse_args()
97
+
98
+ extra_params: dict[str, Any] | None = None
99
+ if args.extra_params is not None:
100
+ try:
101
+ extra_params = json.loads(args.extra_params)
102
+ except json.JSONDecodeError as e:
103
+ print(f"Error: --extra-params is not valid JSON: {e}", file=sys.stderr)
104
+ raise SystemExit(1)
105
+ if not isinstance(extra_params, dict):
106
+ print("Error: --extra-params must be a JSON object", file=sys.stderr)
107
+ raise SystemExit(1)
108
+
109
+ try:
110
+ asyncio.run(
111
+ _run(
112
+ args.model,
113
+ args.prompt,
114
+ args.base_url,
115
+ extra_params,
116
+ args.stream_thinking,
117
+ args.debug or args.pretty,
118
+ args.pretty,
119
+ )
120
+ )
121
+ except Exception as e:
122
+ print(f"Error: {e}", file=sys.stderr)
123
+ raise SystemExit(1)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,338 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import os
5
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
6
+ from contextlib import AbstractAsyncContextManager
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any, ClassVar, Self
9
+
10
+ import niquests
11
+ from niquests.typing import JSONEncoderType
12
+ from urllib3.util.retry import Retry
13
+
14
+ from ._deprecation import warn_if_deprecated
15
+ from .conversation import ChatMessage
16
+ from .errors import LLMError, Provider
17
+ from .logs import log
18
+ from .models import ChatResponse, ToolCall, ToolDefinition, UsageToken
19
+
20
+ if TYPE_CHECKING:
21
+ from types import TracebackType
22
+
23
+ __all__ = (
24
+ "NO_TURN_DETECTION",
25
+ "ChatStream",
26
+ "LLMClientBase",
27
+ "OnThought",
28
+ "Provider",
29
+ "RealtimeClientBase",
30
+ )
31
+
32
+ OnThought = Callable[[str], None]
33
+
34
+ # Pass as ``turn_detection`` to disable server-side VAD (manual / push-to-talk);
35
+ # you then drive each turn yourself with the provider's manual-turn methods.
36
+ NO_TURN_DETECTION = "none"
37
+
38
+
39
+ class ChatStream(abc.ABC):
40
+ """Async iterator that yields text chunks and captures usage/tool_calls after iteration."""
41
+
42
+ usage: UsageToken | None = None
43
+ tool_calls: list[ToolCall] | None = None
44
+
45
+ @abc.abstractmethod
46
+ def __aiter__(self) -> AsyncIterator[str]:
47
+ """Iterate over text chunks from the stream."""
48
+ ...
49
+
50
+
51
+ def env_api_key(provider: Provider, var: str) -> str:
52
+ """Read an API key from environment variable *var*, raising :class:`LLMError` if unset."""
53
+ api_key = os.environ.get(var)
54
+ if not api_key:
55
+ raise LLMError(provider, f"{var} not set")
56
+ return api_key
57
+
58
+
59
+ def to_sse_url(url: str) -> str:
60
+ """Convert http(s):// URL to the niquests SSE scheme (``sse://`` or ``psse://``)."""
61
+ stripped = url.removeprefix("https://")
62
+ if stripped is not url:
63
+ return "sse://" + stripped
64
+ return "psse://" + url.removeprefix("http://")
65
+
66
+
67
+ @dataclass
68
+ class LLMClientBase[RetryT: Retry](abc.ABC):
69
+ """Abstract base class for LLM provider clients.
70
+
71
+ Use provider-specific clients (OpenAIClient, GeminiClient, etc.) instead.
72
+ """
73
+
74
+ provider: ClassVar[Provider]
75
+ _deprecations: ClassVar[Mapping[str, str]] = {}
76
+ """Model id -> retirement date for provider-deprecated models. Set per provider."""
77
+ _retry: RetryT
78
+
79
+ model: str | None = None
80
+ """Default model to use (e.g., 'gpt-4o', 'gemini-2.5-flash')."""
81
+ temperature: float = 0.2
82
+ """Sampling temperature."""
83
+ timeout: float = 60
84
+ """Request timeout in seconds."""
85
+ api_key: str | None = field(default=None, repr=False)
86
+ """API key. If None, reads from provider's environment variable."""
87
+ on_thought: OnThought | None = field(default=None, repr=False)
88
+ """Callback invoked with each chunk of model "thinking" / reasoning text.
89
+ Clients that don't support thinking yet simply never
90
+ invoke it."""
91
+ json_encoder: JSONEncoderType | None = field(default=None, repr=False)
92
+ """Serializer applied to ``json=`` request payloads (e.g. a msgspec/pydantic
93
+ encoder returning str or bytes). None keeps niquests' default encoding."""
94
+
95
+ _api_key: str = field(init=False, repr=False)
96
+ base_url: str = field(init=False, default="", repr=False)
97
+ _session: niquests.AsyncSession | None = field(init=False, default=None, repr=False)
98
+
99
+ @property
100
+ def max_retries(self) -> int | None:
101
+ """Maximum retry attempts, derived from _retry config. None means unlimited, 0 means disabled."""
102
+ total = self._retry.total
103
+ if total is False:
104
+ return 0
105
+ if total is None:
106
+ return None
107
+ return int(total)
108
+
109
+ def __post_init__(self) -> None:
110
+ warn_if_deprecated(self.provider, self.model, self._deprecations)
111
+ self._api_key = self.api_key or self._get_default_api_key()
112
+
113
+ @abc.abstractmethod
114
+ def _get_default_api_key(self) -> str:
115
+ """Get API key from environment."""
116
+ ...
117
+
118
+ @abc.abstractmethod
119
+ def _set_auth_headers(self, session: niquests.AsyncSession) -> None:
120
+ """Set provider-specific authentication headers."""
121
+ ...
122
+
123
+ @property
124
+ def session(self) -> niquests.AsyncSession:
125
+ """Get active session, raising if not initialized."""
126
+ if self._session is None:
127
+ raise LLMError(
128
+ self.provider, "Client not initialized. Use async context manager."
129
+ )
130
+ return self._session
131
+
132
+ async def _iter_sse(
133
+ self, resp: niquests.Response, done_sentinel: str | None = None
134
+ ) -> AsyncGenerator[dict]:
135
+ """Yield parsed JSON payloads from an SSE response, stopping at
136
+ *done_sentinel* and closing a half-read stream so its pooled
137
+ connection is released (a leaked lease starves later requests)."""
138
+ ext = resp.extension
139
+ if ext is None:
140
+ raise LLMError(self.provider, "SSE extension not available on response")
141
+ try:
142
+ while not ext.closed:
143
+ event = await ext.next_payload()
144
+ if event is None:
145
+ break
146
+ if not event.data:
147
+ continue
148
+ if event.data == done_sentinel:
149
+ break
150
+ try:
151
+ yield event.json()
152
+ except ValueError as e:
153
+ raise LLMError(self.provider, f"Stream parse error: {e}") from e
154
+ finally:
155
+ if not ext.closed:
156
+ # Best-effort: aborting an already-finished HTTP/2 stream raises
157
+ # inside urllib3-future (KeyError on the stream id); a cleanup
158
+ # failure must never corrupt an otherwise successful stream.
159
+ try:
160
+ await ext.close()
161
+ except Exception as e:
162
+ log.debug("SSE extension close failed: %s", e)
163
+ if (raw := resp.raw) is not None:
164
+ try:
165
+ await raw.close()
166
+ except Exception as e:
167
+ log.debug("SSE raw close failed: %s", e)
168
+ release_conn = getattr(raw, "release_conn", None)
169
+ if release_conn is not None:
170
+ try:
171
+ release_conn()
172
+ except Exception as e:
173
+ log.debug("SSE connection release failed: %s", e)
174
+
175
+ def _sse_url(self, path: str) -> str:
176
+ """Build a full SSE-scheme URL for the given path.
177
+
178
+ niquests activates its SSE extension (``r.extension``) only when
179
+ the request URL uses the ``sse://`` (TLS) or ``psse://`` (plain)
180
+ scheme. This helper resolves *path* against ``base_url`` and
181
+ swaps the scheme so streaming requests get proper SSE parsing.
182
+ """
183
+ return to_sse_url(self.base_url.rstrip("/") + "/" + path.lstrip("/"))
184
+
185
+ async def __aenter__(self) -> Self:
186
+ if self._session is not None:
187
+ raise RuntimeError(
188
+ f"{type(self).__name__} is already open; "
189
+ "using the same client in nested `async with` blocks is not supported"
190
+ )
191
+ self._session = niquests.AsyncSession(
192
+ timeout=self.timeout,
193
+ retries=self._retry,
194
+ base_url=self.base_url,
195
+ json_encoder=self.json_encoder,
196
+ )
197
+ self._set_auth_headers(self._session)
198
+ return self
199
+
200
+ async def __aexit__(
201
+ self,
202
+ exc_type: type[BaseException] | None,
203
+ exc_val: BaseException | None,
204
+ exc_tb: TracebackType | None,
205
+ ) -> None:
206
+ if self._session:
207
+ try:
208
+ await self._session.close()
209
+ finally:
210
+ self._session = None
211
+
212
+ @abc.abstractmethod
213
+ async def complete(self, body: Any) -> tuple[Any, UsageToken]:
214
+ """Fetch a structured completion from the provider.
215
+
216
+ Each provider defines its own body and response types.
217
+ """
218
+ ...
219
+
220
+ @abc.abstractmethod
221
+ def stream(self, body: Any) -> AsyncIterator[Any]:
222
+ """Stream chat completions, yielding response chunks as they arrive.
223
+
224
+ Each provider defines its own body and response types.
225
+ """
226
+ ...
227
+
228
+ @abc.abstractmethod
229
+ def stream_chat(
230
+ self,
231
+ messages: Sequence[ChatMessage],
232
+ tools: Sequence[ToolDefinition] | None = None,
233
+ extra_params: dict[str, Any] | None = None,
234
+ ) -> ChatStream:
235
+ """Stream a chat conversation, yielding text chunks.
236
+
237
+ This is a higher-level API than stream() that handles provider-specific
238
+ body building and response extraction. Usage and tool_calls are available
239
+ on the returned ChatStream object after iteration completes.
240
+
241
+ The optional ``extra_params`` dict is merged into the request body before
242
+ it is sent, letting callers pass provider-specific fields that are not
243
+ part of the standard interface (e.g. NVIDIA's ``chat_template_kwargs``).
244
+ Providers that don't support extra body fields may silently ignore it.
245
+ """
246
+ ...
247
+
248
+ @abc.abstractmethod
249
+ async def complete_chat(
250
+ self,
251
+ messages: Sequence[ChatMessage],
252
+ tools: Sequence[ToolDefinition] | None = None,
253
+ ) -> tuple[ChatResponse, UsageToken]:
254
+ """Send a chat conversation and return the structured response.
255
+
256
+ Returns a ChatResponse with content, optional tool_calls, and finish_reason,
257
+ along with token usage.
258
+ """
259
+ ...
260
+
261
+
262
+ @dataclass
263
+ class RealtimeClientBase[ConnT](abc.ABC):
264
+ """Abstract base for realtime speech-to-speech clients over a WebSocket."""
265
+
266
+ provider: ClassVar[Provider]
267
+
268
+ model: str
269
+ api_key: str | None = field(default=None, repr=False)
270
+ base_url: str = field(kw_only=True)
271
+ timeout: float = 30.0
272
+ session_kwargs: Mapping[str, Any] | None = field(default=None, repr=False)
273
+ """Constructor arguments (e.g. proxies) for the managed ``AsyncSession``."""
274
+
275
+ _api_key: str = field(init=False, repr=False)
276
+ _session: niquests.AsyncSession | None = field(init=False, default=None, repr=False)
277
+ _conn_cm: AbstractAsyncContextManager[Any] | None = field(
278
+ init=False, default=None, repr=False
279
+ )
280
+
281
+ def __post_init__(self) -> None:
282
+ self._api_key = self.api_key or self._get_default_api_key()
283
+
284
+ @property
285
+ def session(self) -> niquests.AsyncSession:
286
+ """Get active session, raising if not initialized."""
287
+ if self._session is None:
288
+ raise LLMError(
289
+ self.provider, "Client not initialized. Use async context manager."
290
+ )
291
+ return self._session
292
+
293
+ async def __aenter__(self) -> ConnT:
294
+ if self._session is not None:
295
+ raise RuntimeError(
296
+ f"{type(self).__name__} is already open; "
297
+ "using the same client in nested `async with` blocks is not supported"
298
+ )
299
+ self._session = niquests.AsyncSession(**(self.session_kwargs or {}))
300
+ self._set_auth_headers(self._session)
301
+ try:
302
+ self._conn_cm = self._connect()
303
+ return await self._conn_cm.__aenter__()
304
+ except BaseException:
305
+ await self.__aexit__(None, None, None)
306
+ raise
307
+
308
+ async def __aexit__(
309
+ self,
310
+ exc_type: type[BaseException] | None,
311
+ exc_val: BaseException | None,
312
+ exc_tb: TracebackType | None,
313
+ ) -> None:
314
+ try:
315
+ if self._conn_cm is not None:
316
+ await self._conn_cm.__aexit__(exc_type, exc_val, exc_tb)
317
+ finally:
318
+ self._conn_cm = None
319
+ if self._session:
320
+ try:
321
+ await self._session.close()
322
+ finally:
323
+ self._session = None
324
+
325
+ @abc.abstractmethod
326
+ def _get_default_api_key(self) -> str:
327
+ """Get API key from environment."""
328
+ ...
329
+
330
+ @abc.abstractmethod
331
+ def _set_auth_headers(self, session: niquests.AsyncSession) -> None:
332
+ """Set provider-specific authentication headers."""
333
+ ...
334
+
335
+ @abc.abstractmethod
336
+ def _connect(self) -> AbstractAsyncContextManager[ConnT]:
337
+ """Open the provider handshake over the client's session."""
338
+ ...