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.
- padwan_ai-0.11.0/PKG-INFO +15 -0
- padwan_ai-0.11.0/padwan_ai/__init__.py +197 -0
- padwan_ai-0.11.0/padwan_ai/__main__.py +127 -0
- padwan_ai-0.11.0/padwan_ai/_base.py +338 -0
- padwan_ai-0.11.0/padwan_ai/_deprecation.py +50 -0
- padwan_ai-0.11.0/padwan_ai/_json.py +16 -0
- padwan_ai-0.11.0/padwan_ai/_ws.py +79 -0
- padwan_ai-0.11.0/padwan_ai/agent.py +713 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/__init__.py +30 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/audio.py +8 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/client.py +375 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/compat.py +281 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/events.py +280 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/models.py +123 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/tools.py +83 -0
- padwan_ai-0.11.0/padwan_ai/anthropic/vision.py +6 -0
- padwan_ai-0.11.0/padwan_ai/audio.py +34 -0
- padwan_ai-0.11.0/padwan_ai/client.py +298 -0
- padwan_ai-0.11.0/padwan_ai/content.py +148 -0
- padwan_ai-0.11.0/padwan_ai/conversation.py +177 -0
- padwan_ai-0.11.0/padwan_ai/errors.py +52 -0
- padwan_ai-0.11.0/padwan_ai/gemini/__init__.py +52 -0
- padwan_ai-0.11.0/padwan_ai/gemini/audio.py +14 -0
- padwan_ai-0.11.0/padwan_ai/gemini/batch.py +113 -0
- padwan_ai-0.11.0/padwan_ai/gemini/client.py +636 -0
- padwan_ai-0.11.0/padwan_ai/gemini/models.py +254 -0
- padwan_ai-0.11.0/padwan_ai/gemini/realtime.py +219 -0
- padwan_ai-0.11.0/padwan_ai/gemini/tools.py +115 -0
- padwan_ai-0.11.0/padwan_ai/gemini/vision.py +6 -0
- padwan_ai-0.11.0/padwan_ai/grok/__init__.py +38 -0
- padwan_ai-0.11.0/padwan_ai/grok/audio.py +8 -0
- padwan_ai-0.11.0/padwan_ai/grok/batch.py +128 -0
- padwan_ai-0.11.0/padwan_ai/grok/client.py +209 -0
- padwan_ai-0.11.0/padwan_ai/grok/realtime.py +43 -0
- padwan_ai-0.11.0/padwan_ai/grok/types.py +119 -0
- padwan_ai-0.11.0/padwan_ai/grok/vision.py +6 -0
- padwan_ai-0.11.0/padwan_ai/langfuse.py +269 -0
- padwan_ai-0.11.0/padwan_ai/logs.py +3 -0
- padwan_ai-0.11.0/padwan_ai/mcp.py +854 -0
- padwan_ai-0.11.0/padwan_ai/mistral/__init__.py +33 -0
- padwan_ai-0.11.0/padwan_ai/mistral/_deprecations.py +4 -0
- padwan_ai-0.11.0/padwan_ai/mistral/audio.py +15 -0
- padwan_ai-0.11.0/padwan_ai/mistral/client.py +219 -0
- padwan_ai-0.11.0/padwan_ai/mistral/types.py +413 -0
- padwan_ai-0.11.0/padwan_ai/mistral/vision.py +6 -0
- padwan_ai-0.11.0/padwan_ai/models.py +70 -0
- padwan_ai-0.11.0/padwan_ai/openai/__init__.py +55 -0
- padwan_ai-0.11.0/padwan_ai/openai/audio.py +14 -0
- padwan_ai-0.11.0/padwan_ai/openai/batch.py +126 -0
- padwan_ai-0.11.0/padwan_ai/openai/client.py +556 -0
- padwan_ai-0.11.0/padwan_ai/openai/realtime.py +235 -0
- padwan_ai-0.11.0/padwan_ai/openai/tools.py +91 -0
- padwan_ai-0.11.0/padwan_ai/openai/types.py +1164 -0
- padwan_ai-0.11.0/padwan_ai/openai/vision.py +18 -0
- padwan_ai-0.11.0/padwan_ai/otel.py +1557 -0
- padwan_ai-0.11.0/padwan_ai/testing.py +142 -0
- padwan_ai-0.11.0/padwan_ai/tools.py +248 -0
- padwan_ai-0.11.0/padwan_ai/typesafe/__init__.py +37 -0
- padwan_ai-0.11.0/padwan_ai/typesafe/client.py +159 -0
- padwan_ai-0.11.0/padwan_ai/typesafe/models.py +81 -0
- padwan_ai-0.11.0/padwan_ai/vision.py +30 -0
- padwan_ai-0.11.0/pyproject.toml +105 -0
- 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
|
+
...
|