nous-genai 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.
- nous/__init__.py +3 -0
- nous/genai/__init__.py +56 -0
- nous/genai/__main__.py +3 -0
- nous/genai/_internal/__init__.py +1 -0
- nous/genai/_internal/capability_rules.py +476 -0
- nous/genai/_internal/config.py +102 -0
- nous/genai/_internal/errors.py +63 -0
- nous/genai/_internal/http.py +951 -0
- nous/genai/_internal/json_schema.py +54 -0
- nous/genai/cli.py +1316 -0
- nous/genai/client.py +719 -0
- nous/genai/mcp_cli.py +275 -0
- nous/genai/mcp_server.py +1080 -0
- nous/genai/providers/__init__.py +15 -0
- nous/genai/providers/aliyun.py +535 -0
- nous/genai/providers/anthropic.py +483 -0
- nous/genai/providers/gemini.py +1606 -0
- nous/genai/providers/openai.py +1909 -0
- nous/genai/providers/tuzi.py +1158 -0
- nous/genai/providers/volcengine.py +273 -0
- nous/genai/reference/__init__.py +17 -0
- nous/genai/reference/catalog.py +206 -0
- nous/genai/reference/mappings.py +467 -0
- nous/genai/reference/mode_overrides.py +26 -0
- nous/genai/reference/model_catalog.py +82 -0
- nous/genai/reference/model_catalog_data/__init__.py +1 -0
- nous/genai/reference/model_catalog_data/aliyun.py +98 -0
- nous/genai/reference/model_catalog_data/anthropic.py +10 -0
- nous/genai/reference/model_catalog_data/google.py +45 -0
- nous/genai/reference/model_catalog_data/openai.py +44 -0
- nous/genai/reference/model_catalog_data/tuzi_anthropic.py +21 -0
- nous/genai/reference/model_catalog_data/tuzi_google.py +19 -0
- nous/genai/reference/model_catalog_data/tuzi_openai.py +75 -0
- nous/genai/reference/model_catalog_data/tuzi_web.py +136 -0
- nous/genai/reference/model_catalog_data/volcengine.py +107 -0
- nous/genai/tools/__init__.py +13 -0
- nous/genai/tools/output_parser.py +119 -0
- nous/genai/types.py +416 -0
- nous/py.typed +1 -0
- nous_genai-0.1.0.dist-info/METADATA +200 -0
- nous_genai-0.1.0.dist-info/RECORD +45 -0
- nous_genai-0.1.0.dist-info/WHEEL +5 -0
- nous_genai-0.1.0.dist-info/entry_points.txt +4 -0
- nous_genai-0.1.0.dist-info/licenses/LICENSE +190 -0
- nous_genai-0.1.0.dist-info/top_level.txt +1 -0
nous/genai/client.py
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import os
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import Iterator, Literal, Protocol, overload
|
|
9
|
+
|
|
10
|
+
from ._internal.config import get_default_timeout_ms, get_provider_keys, load_env_files
|
|
11
|
+
from ._internal.errors import GenAIError, invalid_request_error, not_supported_error
|
|
12
|
+
from ._internal.http import download_to_file as _download_to_file
|
|
13
|
+
from ._internal.http import download_to_tempfile
|
|
14
|
+
from ._internal.json_schema import (
|
|
15
|
+
normalize_json_schema,
|
|
16
|
+
reject_gemini_response_schema_dict,
|
|
17
|
+
)
|
|
18
|
+
from .types import (
|
|
19
|
+
Capability,
|
|
20
|
+
GenerateEvent,
|
|
21
|
+
GenerateRequest,
|
|
22
|
+
GenerateResponse,
|
|
23
|
+
Message,
|
|
24
|
+
Part,
|
|
25
|
+
PartSourceBytes,
|
|
26
|
+
PartSourceUrl,
|
|
27
|
+
sniff_image_mime_type,
|
|
28
|
+
)
|
|
29
|
+
from .providers import (
|
|
30
|
+
AliyunAdapter,
|
|
31
|
+
AnthropicAdapter,
|
|
32
|
+
GeminiAdapter,
|
|
33
|
+
OpenAIAdapter,
|
|
34
|
+
TuziAdapter,
|
|
35
|
+
VolcengineAdapter,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _ArtifactStore(Protocol):
|
|
40
|
+
def put(self, data: bytes, mime_type: str | None) -> str | None: ...
|
|
41
|
+
def url(self, artifact_id: str) -> str: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Client:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
proxy_url: str | None = None,
|
|
49
|
+
artifact_store: _ArtifactStore | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
load_env_files()
|
|
52
|
+
self._transport = os.environ.get("NOUS_GENAI_TRANSPORT", "").strip().lower()
|
|
53
|
+
self._proxy_url = (
|
|
54
|
+
proxy_url.strip()
|
|
55
|
+
if isinstance(proxy_url, str) and proxy_url.strip()
|
|
56
|
+
else None
|
|
57
|
+
)
|
|
58
|
+
self._artifact_store = artifact_store
|
|
59
|
+
keys = get_provider_keys()
|
|
60
|
+
self._openai = (
|
|
61
|
+
OpenAIAdapter(api_key=keys.openai_api_key, proxy_url=self._proxy_url)
|
|
62
|
+
if keys.openai_api_key
|
|
63
|
+
else None
|
|
64
|
+
)
|
|
65
|
+
self._gemini = (
|
|
66
|
+
GeminiAdapter(api_key=keys.google_api_key, proxy_url=self._proxy_url)
|
|
67
|
+
if keys.google_api_key
|
|
68
|
+
else None
|
|
69
|
+
)
|
|
70
|
+
self._anthropic = (
|
|
71
|
+
AnthropicAdapter(api_key=keys.anthropic_api_key, proxy_url=self._proxy_url)
|
|
72
|
+
if keys.anthropic_api_key
|
|
73
|
+
else None
|
|
74
|
+
)
|
|
75
|
+
self._aliyun = None
|
|
76
|
+
if keys.aliyun_api_key:
|
|
77
|
+
self._aliyun = AliyunAdapter(
|
|
78
|
+
openai=OpenAIAdapter(
|
|
79
|
+
api_key=keys.aliyun_api_key,
|
|
80
|
+
base_url=os.environ.get(
|
|
81
|
+
"ALIYUN_OAI_BASE_URL",
|
|
82
|
+
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
83
|
+
).rstrip("/"),
|
|
84
|
+
provider_name="aliyun",
|
|
85
|
+
chat_api="chat_completions",
|
|
86
|
+
proxy_url=self._proxy_url,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
self._volcengine = None
|
|
90
|
+
if keys.volcengine_api_key:
|
|
91
|
+
self._volcengine = VolcengineAdapter(
|
|
92
|
+
openai=OpenAIAdapter(
|
|
93
|
+
api_key=keys.volcengine_api_key,
|
|
94
|
+
base_url=os.environ.get(
|
|
95
|
+
"VOLCENGINE_OAI_BASE_URL",
|
|
96
|
+
"https://ark.cn-beijing.volces.com/api/v3",
|
|
97
|
+
).rstrip("/"),
|
|
98
|
+
provider_name="volcengine",
|
|
99
|
+
chat_api="chat_completions",
|
|
100
|
+
proxy_url=self._proxy_url,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
base_host = os.environ.get("TUZI_BASE_URL", "https://api.tu-zi.com").rstrip("/")
|
|
104
|
+
self._tuzi_web = None
|
|
105
|
+
if keys.tuzi_web_api_key:
|
|
106
|
+
self._tuzi_web = TuziAdapter(
|
|
107
|
+
openai=OpenAIAdapter(
|
|
108
|
+
api_key=keys.tuzi_web_api_key,
|
|
109
|
+
base_url=os.environ.get(
|
|
110
|
+
"TUZI_OAI_BASE_URL", f"{base_host}/v1"
|
|
111
|
+
).rstrip("/"),
|
|
112
|
+
provider_name="tuzi-web",
|
|
113
|
+
chat_api="chat_completions",
|
|
114
|
+
proxy_url=self._proxy_url,
|
|
115
|
+
),
|
|
116
|
+
gemini=GeminiAdapter(
|
|
117
|
+
api_key=keys.tuzi_web_api_key,
|
|
118
|
+
base_url=os.environ.get("TUZI_GOOGLE_BASE_URL", base_host).rstrip(
|
|
119
|
+
"/"
|
|
120
|
+
),
|
|
121
|
+
provider_name="tuzi-web",
|
|
122
|
+
auth_mode="bearer",
|
|
123
|
+
supports_file_upload=False,
|
|
124
|
+
proxy_url=self._proxy_url,
|
|
125
|
+
),
|
|
126
|
+
anthropic=AnthropicAdapter(
|
|
127
|
+
api_key=keys.tuzi_web_api_key,
|
|
128
|
+
base_url=os.environ.get(
|
|
129
|
+
"TUZI_ANTHROPIC_BASE_URL", base_host
|
|
130
|
+
).rstrip("/"),
|
|
131
|
+
provider_name="tuzi-web",
|
|
132
|
+
auth_mode="bearer",
|
|
133
|
+
proxy_url=self._proxy_url,
|
|
134
|
+
),
|
|
135
|
+
proxy_url=self._proxy_url,
|
|
136
|
+
)
|
|
137
|
+
self._tuzi_openai = None
|
|
138
|
+
if keys.tuzi_openai_api_key:
|
|
139
|
+
self._tuzi_openai = OpenAIAdapter(
|
|
140
|
+
api_key=keys.tuzi_openai_api_key,
|
|
141
|
+
base_url=os.environ.get("TUZI_OAI_BASE_URL", f"{base_host}/v1").rstrip(
|
|
142
|
+
"/"
|
|
143
|
+
),
|
|
144
|
+
provider_name="tuzi-openai",
|
|
145
|
+
chat_api="chat_completions",
|
|
146
|
+
proxy_url=self._proxy_url,
|
|
147
|
+
)
|
|
148
|
+
self._tuzi_google = None
|
|
149
|
+
if keys.tuzi_google_api_key:
|
|
150
|
+
self._tuzi_google = GeminiAdapter(
|
|
151
|
+
api_key=keys.tuzi_google_api_key,
|
|
152
|
+
base_url=os.environ.get("TUZI_GOOGLE_BASE_URL", base_host).rstrip("/"),
|
|
153
|
+
provider_name="tuzi-google",
|
|
154
|
+
auth_mode="bearer",
|
|
155
|
+
supports_file_upload=False,
|
|
156
|
+
proxy_url=self._proxy_url,
|
|
157
|
+
)
|
|
158
|
+
self._tuzi_anthropic = None
|
|
159
|
+
if keys.tuzi_anthropic_api_key:
|
|
160
|
+
self._tuzi_anthropic = AnthropicAdapter(
|
|
161
|
+
api_key=keys.tuzi_anthropic_api_key,
|
|
162
|
+
base_url=os.environ.get("TUZI_ANTHROPIC_BASE_URL", base_host).rstrip(
|
|
163
|
+
"/"
|
|
164
|
+
),
|
|
165
|
+
provider_name="tuzi-anthropic",
|
|
166
|
+
auth_mode="bearer",
|
|
167
|
+
proxy_url=self._proxy_url,
|
|
168
|
+
)
|
|
169
|
+
self._default_timeout_ms = get_default_timeout_ms()
|
|
170
|
+
|
|
171
|
+
def capabilities(self, model: str) -> Capability:
|
|
172
|
+
provider, model_id = _split_model(model)
|
|
173
|
+
adapter = self._adapter(provider)
|
|
174
|
+
return adapter.capabilities(model_id)
|
|
175
|
+
|
|
176
|
+
def list_provider_models(
|
|
177
|
+
self, provider: str, *, timeout_ms: int | None = None
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
provider = _normalize_provider(provider)
|
|
180
|
+
try:
|
|
181
|
+
adapter = self._adapter(provider)
|
|
182
|
+
except GenAIError:
|
|
183
|
+
return []
|
|
184
|
+
fn = getattr(adapter, "list_models", None)
|
|
185
|
+
if not callable(fn):
|
|
186
|
+
return []
|
|
187
|
+
try:
|
|
188
|
+
models = fn(timeout_ms=timeout_ms)
|
|
189
|
+
except GenAIError:
|
|
190
|
+
return []
|
|
191
|
+
return [m for m in models if isinstance(m, str) and m]
|
|
192
|
+
|
|
193
|
+
def list_available_models(
|
|
194
|
+
self, provider: str, *, timeout_ms: int | None = None
|
|
195
|
+
) -> list[str]:
|
|
196
|
+
"""
|
|
197
|
+
List models that are both:
|
|
198
|
+
- included in the SDK curated catalog, and
|
|
199
|
+
- remotely available for the current credentials.
|
|
200
|
+
"""
|
|
201
|
+
from .reference import get_model_catalog
|
|
202
|
+
|
|
203
|
+
p = _normalize_provider(provider)
|
|
204
|
+
supported = {
|
|
205
|
+
m for m in get_model_catalog().get(p, []) if isinstance(m, str) and m
|
|
206
|
+
}
|
|
207
|
+
if not supported:
|
|
208
|
+
return []
|
|
209
|
+
remote = set(self.list_provider_models(p, timeout_ms=timeout_ms))
|
|
210
|
+
if not remote:
|
|
211
|
+
return []
|
|
212
|
+
return sorted(supported & remote)
|
|
213
|
+
|
|
214
|
+
def list_all_available_models(self, *, timeout_ms: int | None = None) -> list[str]:
|
|
215
|
+
"""
|
|
216
|
+
List available models across all SDK-supported providers.
|
|
217
|
+
|
|
218
|
+
Returns fully-qualified model strings like "openai:gpt-4o-mini".
|
|
219
|
+
"""
|
|
220
|
+
from .reference import get_supported_providers
|
|
221
|
+
|
|
222
|
+
out: list[str] = []
|
|
223
|
+
for provider in sorted(get_supported_providers()):
|
|
224
|
+
p = _normalize_provider(provider)
|
|
225
|
+
for model_id in self.list_available_models(p, timeout_ms=timeout_ms):
|
|
226
|
+
out.append(f"{p}:{model_id}")
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
def list_unsupported_models(
|
|
230
|
+
self, provider: str, *, timeout_ms: int | None = None
|
|
231
|
+
) -> list[str]:
|
|
232
|
+
"""
|
|
233
|
+
List remotely available models that are not in the SDK curated catalog.
|
|
234
|
+
"""
|
|
235
|
+
from .reference import get_model_catalog
|
|
236
|
+
|
|
237
|
+
p = _normalize_provider(provider)
|
|
238
|
+
supported = {
|
|
239
|
+
m for m in get_model_catalog().get(p, []) if isinstance(m, str) and m
|
|
240
|
+
}
|
|
241
|
+
remote = set(self.list_provider_models(p, timeout_ms=timeout_ms))
|
|
242
|
+
if not remote:
|
|
243
|
+
return []
|
|
244
|
+
return sorted(remote - supported)
|
|
245
|
+
|
|
246
|
+
def list_stale_models(
|
|
247
|
+
self, provider: str, *, timeout_ms: int | None = None
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
"""
|
|
250
|
+
List models that are in the SDK curated catalog, but not remotely available for the current credentials.
|
|
251
|
+
"""
|
|
252
|
+
from .reference import get_model_catalog
|
|
253
|
+
|
|
254
|
+
p = _normalize_provider(provider)
|
|
255
|
+
supported = {
|
|
256
|
+
m for m in get_model_catalog().get(p, []) if isinstance(m, str) and m
|
|
257
|
+
}
|
|
258
|
+
if not supported:
|
|
259
|
+
return []
|
|
260
|
+
remote = set(self.list_provider_models(p, timeout_ms=timeout_ms))
|
|
261
|
+
if not remote:
|
|
262
|
+
return []
|
|
263
|
+
return sorted(supported - remote)
|
|
264
|
+
|
|
265
|
+
@overload
|
|
266
|
+
def generate(
|
|
267
|
+
self,
|
|
268
|
+
request: GenerateRequest,
|
|
269
|
+
*,
|
|
270
|
+
stream: Literal[False] = False,
|
|
271
|
+
) -> GenerateResponse: ...
|
|
272
|
+
|
|
273
|
+
@overload
|
|
274
|
+
def generate(
|
|
275
|
+
self,
|
|
276
|
+
request: GenerateRequest,
|
|
277
|
+
*,
|
|
278
|
+
stream: Literal[True],
|
|
279
|
+
) -> Iterator[GenerateEvent]: ...
|
|
280
|
+
|
|
281
|
+
def generate(
|
|
282
|
+
self,
|
|
283
|
+
request: GenerateRequest,
|
|
284
|
+
*,
|
|
285
|
+
stream: bool = False,
|
|
286
|
+
) -> GenerateResponse | Iterator[GenerateEvent]:
|
|
287
|
+
if _is_mcp_transport_marker(self._transport):
|
|
288
|
+
_validate_mcp_wire_request(request)
|
|
289
|
+
provider = _normalize_provider(request.provider())
|
|
290
|
+
adapter = self._adapter(provider)
|
|
291
|
+
if request.params.timeout_ms is None:
|
|
292
|
+
request = replace(
|
|
293
|
+
request,
|
|
294
|
+
params=replace(request.params, timeout_ms=self._default_timeout_ms),
|
|
295
|
+
)
|
|
296
|
+
request = _normalize_output_text_json_schema(request)
|
|
297
|
+
cap = adapter.capabilities(request.model_id())
|
|
298
|
+
in_modalities: set[str] = set()
|
|
299
|
+
for msg in request.input:
|
|
300
|
+
for part in msg.content:
|
|
301
|
+
if part.type in {"text", "image", "audio", "video", "embedding"}:
|
|
302
|
+
in_modalities.add(part.type)
|
|
303
|
+
elif part.type == "file":
|
|
304
|
+
raise not_supported_error(
|
|
305
|
+
"file parts are not supported in request.input; use image/audio/video parts"
|
|
306
|
+
)
|
|
307
|
+
if not in_modalities.issubset(cap.input_modalities):
|
|
308
|
+
raise not_supported_error(
|
|
309
|
+
f"requested input modalities not supported: {sorted(in_modalities)} (supported: {sorted(cap.input_modalities)})"
|
|
310
|
+
)
|
|
311
|
+
out_modalities = set(request.output.modalities)
|
|
312
|
+
if not out_modalities:
|
|
313
|
+
raise invalid_request_error("output.modalities must not be empty")
|
|
314
|
+
if not out_modalities.issubset(cap.output_modalities):
|
|
315
|
+
raise not_supported_error(
|
|
316
|
+
f"requested output modalities not supported: {sorted(out_modalities)} (supported: {sorted(cap.output_modalities)})"
|
|
317
|
+
)
|
|
318
|
+
if stream and not cap.supports_stream:
|
|
319
|
+
raise not_supported_error("streaming is not supported for this model")
|
|
320
|
+
out = adapter.generate(request, stream=stream)
|
|
321
|
+
if isinstance(out, GenerateResponse):
|
|
322
|
+
out = self._externalize_large_base64_parts(out)
|
|
323
|
+
return self._externalize_protected_url_parts(
|
|
324
|
+
out, adapter=adapter, timeout_ms=request.params.timeout_ms
|
|
325
|
+
)
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
def _externalize_large_base64_parts(
|
|
329
|
+
self, resp: GenerateResponse
|
|
330
|
+
) -> GenerateResponse:
|
|
331
|
+
store = self._artifact_store
|
|
332
|
+
if store is None:
|
|
333
|
+
return resp
|
|
334
|
+
|
|
335
|
+
max_inline_b64_chars = _env_int("NOUS_GENAI_MAX_INLINE_BASE64_CHARS", 4096)
|
|
336
|
+
if max_inline_b64_chars < 0:
|
|
337
|
+
max_inline_b64_chars = 0
|
|
338
|
+
if max_inline_b64_chars == 0:
|
|
339
|
+
threshold = 1
|
|
340
|
+
else:
|
|
341
|
+
threshold = max_inline_b64_chars
|
|
342
|
+
|
|
343
|
+
max_artifact_bytes = _env_int("NOUS_GENAI_MAX_ARTIFACT_BYTES", 64 * 1024 * 1024)
|
|
344
|
+
if max_artifact_bytes < 0:
|
|
345
|
+
max_artifact_bytes = 0
|
|
346
|
+
if max_artifact_bytes == 0:
|
|
347
|
+
return resp
|
|
348
|
+
|
|
349
|
+
out_msgs: list[Message] = []
|
|
350
|
+
changed = False
|
|
351
|
+
for msg in resp.output:
|
|
352
|
+
out_parts: list[Part] = []
|
|
353
|
+
for part in msg.content:
|
|
354
|
+
src = part.source
|
|
355
|
+
if not isinstance(src, PartSourceBytes) or src.encoding != "base64":
|
|
356
|
+
out_parts.append(part)
|
|
357
|
+
continue
|
|
358
|
+
data_b64 = src.data
|
|
359
|
+
if not isinstance(data_b64, str) or len(data_b64) < threshold:
|
|
360
|
+
out_parts.append(part)
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
estimated_bytes = (len(data_b64) * 3) // 4
|
|
364
|
+
if estimated_bytes > max_artifact_bytes:
|
|
365
|
+
out_parts.append(part)
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
data = base64.b64decode(data_b64)
|
|
370
|
+
except Exception:
|
|
371
|
+
out_parts.append(part)
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
mime_type = (
|
|
375
|
+
part.mime_type.strip()
|
|
376
|
+
if isinstance(part.mime_type, str) and part.mime_type.strip()
|
|
377
|
+
else None
|
|
378
|
+
)
|
|
379
|
+
if mime_type is None and part.type == "image":
|
|
380
|
+
mime_type = sniff_image_mime_type(data)
|
|
381
|
+
|
|
382
|
+
artifact_id = store.put(data, mime_type)
|
|
383
|
+
if artifact_id is None:
|
|
384
|
+
out_parts.append(part)
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
out_parts.append(
|
|
388
|
+
Part(
|
|
389
|
+
type=part.type,
|
|
390
|
+
mime_type=mime_type,
|
|
391
|
+
source=PartSourceUrl(url=store.url(artifact_id)),
|
|
392
|
+
text=part.text,
|
|
393
|
+
embedding=part.embedding,
|
|
394
|
+
meta=part.meta,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
changed = True
|
|
398
|
+
out_msgs.append(Message(role=msg.role, content=out_parts))
|
|
399
|
+
|
|
400
|
+
if not changed:
|
|
401
|
+
return resp
|
|
402
|
+
return replace(resp, output=out_msgs)
|
|
403
|
+
|
|
404
|
+
def _externalize_protected_url_parts(
|
|
405
|
+
self,
|
|
406
|
+
resp: GenerateResponse,
|
|
407
|
+
*,
|
|
408
|
+
adapter: object,
|
|
409
|
+
timeout_ms: int | None,
|
|
410
|
+
) -> GenerateResponse:
|
|
411
|
+
store = self._artifact_store
|
|
412
|
+
if store is None:
|
|
413
|
+
return resp
|
|
414
|
+
|
|
415
|
+
max_artifact_bytes = _env_int("NOUS_GENAI_MAX_ARTIFACT_BYTES", 64 * 1024 * 1024)
|
|
416
|
+
if max_artifact_bytes < 0:
|
|
417
|
+
max_artifact_bytes = 0
|
|
418
|
+
if max_artifact_bytes == 0:
|
|
419
|
+
return resp
|
|
420
|
+
|
|
421
|
+
header_fn = getattr(adapter, "_download_headers", None)
|
|
422
|
+
if not callable(header_fn):
|
|
423
|
+
return resp
|
|
424
|
+
base_url = getattr(adapter, "base_url", None)
|
|
425
|
+
if not isinstance(base_url, str) or not base_url.strip():
|
|
426
|
+
return resp
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
raw_headers = header_fn()
|
|
430
|
+
except Exception:
|
|
431
|
+
return resp
|
|
432
|
+
if not isinstance(raw_headers, dict) or not raw_headers:
|
|
433
|
+
return resp
|
|
434
|
+
headers: dict[str, str] = {}
|
|
435
|
+
for k, v in raw_headers.items():
|
|
436
|
+
if isinstance(k, str) and k and isinstance(v, str) and v:
|
|
437
|
+
headers[k] = v
|
|
438
|
+
if not headers:
|
|
439
|
+
return resp
|
|
440
|
+
|
|
441
|
+
host = urllib.parse.urlparse(base_url).hostname
|
|
442
|
+
if not isinstance(host, str) or not host:
|
|
443
|
+
return resp
|
|
444
|
+
host_l = host.lower()
|
|
445
|
+
|
|
446
|
+
out_msgs: list[Message] = []
|
|
447
|
+
changed = False
|
|
448
|
+
for msg in resp.output:
|
|
449
|
+
out_parts: list[Part] = []
|
|
450
|
+
for part in msg.content:
|
|
451
|
+
src = part.source
|
|
452
|
+
if not isinstance(src, PartSourceUrl):
|
|
453
|
+
out_parts.append(part)
|
|
454
|
+
continue
|
|
455
|
+
url = src.url
|
|
456
|
+
if not isinstance(url, str) or not url:
|
|
457
|
+
out_parts.append(part)
|
|
458
|
+
continue
|
|
459
|
+
url_host = urllib.parse.urlparse(url).hostname
|
|
460
|
+
if (
|
|
461
|
+
not isinstance(url_host, str)
|
|
462
|
+
or not url_host
|
|
463
|
+
or url_host.lower() != host_l
|
|
464
|
+
):
|
|
465
|
+
out_parts.append(part)
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
tmp_path: str | None = None
|
|
469
|
+
try:
|
|
470
|
+
tmp_path = download_to_tempfile(
|
|
471
|
+
url=url,
|
|
472
|
+
timeout_ms=timeout_ms,
|
|
473
|
+
max_bytes=max_artifact_bytes,
|
|
474
|
+
headers=headers,
|
|
475
|
+
proxy_url=self._proxy_url,
|
|
476
|
+
)
|
|
477
|
+
with open(tmp_path, "rb") as f:
|
|
478
|
+
data = f.read()
|
|
479
|
+
except Exception:
|
|
480
|
+
out_parts.append(part)
|
|
481
|
+
continue
|
|
482
|
+
finally:
|
|
483
|
+
if tmp_path is not None:
|
|
484
|
+
try:
|
|
485
|
+
os.unlink(tmp_path)
|
|
486
|
+
except OSError:
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
mime_type = (
|
|
490
|
+
part.mime_type.strip()
|
|
491
|
+
if isinstance(part.mime_type, str) and part.mime_type.strip()
|
|
492
|
+
else None
|
|
493
|
+
)
|
|
494
|
+
if mime_type is None and part.type == "image":
|
|
495
|
+
mime_type = sniff_image_mime_type(data)
|
|
496
|
+
|
|
497
|
+
artifact_id = store.put(data, mime_type)
|
|
498
|
+
if artifact_id is None:
|
|
499
|
+
out_parts.append(part)
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
out_parts.append(
|
|
503
|
+
Part(
|
|
504
|
+
type=part.type,
|
|
505
|
+
mime_type=mime_type,
|
|
506
|
+
source=PartSourceUrl(url=store.url(artifact_id)),
|
|
507
|
+
text=part.text,
|
|
508
|
+
embedding=part.embedding,
|
|
509
|
+
meta=part.meta,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
changed = True
|
|
513
|
+
|
|
514
|
+
out_msgs.append(Message(role=msg.role, content=out_parts))
|
|
515
|
+
|
|
516
|
+
if not changed:
|
|
517
|
+
return resp
|
|
518
|
+
return replace(resp, output=out_msgs)
|
|
519
|
+
|
|
520
|
+
def download_to_file(
|
|
521
|
+
self,
|
|
522
|
+
*,
|
|
523
|
+
url: str,
|
|
524
|
+
output_path: str,
|
|
525
|
+
provider: str | None = None,
|
|
526
|
+
timeout_ms: int | None = None,
|
|
527
|
+
max_bytes: int | None = None,
|
|
528
|
+
) -> None:
|
|
529
|
+
headers: dict[str, str] | None = None
|
|
530
|
+
if provider is not None:
|
|
531
|
+
try:
|
|
532
|
+
adapter = self._adapter(_normalize_provider(provider))
|
|
533
|
+
except GenAIError:
|
|
534
|
+
adapter = None
|
|
535
|
+
if adapter is not None:
|
|
536
|
+
header_fn = getattr(adapter, "_download_headers", None)
|
|
537
|
+
base_url = getattr(adapter, "base_url", None)
|
|
538
|
+
if (
|
|
539
|
+
callable(header_fn)
|
|
540
|
+
and isinstance(base_url, str)
|
|
541
|
+
and base_url.strip()
|
|
542
|
+
):
|
|
543
|
+
base_host = urllib.parse.urlparse(base_url).hostname
|
|
544
|
+
url_host = urllib.parse.urlparse(url).hostname
|
|
545
|
+
if (
|
|
546
|
+
isinstance(base_host, str)
|
|
547
|
+
and base_host
|
|
548
|
+
and isinstance(url_host, str)
|
|
549
|
+
and url_host
|
|
550
|
+
and base_host.lower() == url_host.lower()
|
|
551
|
+
):
|
|
552
|
+
try:
|
|
553
|
+
raw = header_fn()
|
|
554
|
+
except Exception:
|
|
555
|
+
raw = None
|
|
556
|
+
if isinstance(raw, dict) and raw:
|
|
557
|
+
sanitized: dict[str, str] = {}
|
|
558
|
+
for k, v in raw.items():
|
|
559
|
+
if (
|
|
560
|
+
isinstance(k, str)
|
|
561
|
+
and k
|
|
562
|
+
and isinstance(v, str)
|
|
563
|
+
and v
|
|
564
|
+
):
|
|
565
|
+
sanitized[k] = v
|
|
566
|
+
if sanitized:
|
|
567
|
+
headers = sanitized
|
|
568
|
+
|
|
569
|
+
_download_to_file(
|
|
570
|
+
url=url,
|
|
571
|
+
output_path=output_path,
|
|
572
|
+
timeout_ms=timeout_ms,
|
|
573
|
+
max_bytes=max_bytes,
|
|
574
|
+
headers=headers,
|
|
575
|
+
proxy_url=self._proxy_url,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def generate_stream(self, request: GenerateRequest) -> Iterator[GenerateEvent]:
|
|
579
|
+
out = self.generate(request, stream=True)
|
|
580
|
+
if not isinstance(out, Iterator):
|
|
581
|
+
raise not_supported_error("provider returned non-stream response")
|
|
582
|
+
return out
|
|
583
|
+
|
|
584
|
+
async def generate_async(self, request: GenerateRequest) -> GenerateResponse:
|
|
585
|
+
"""
|
|
586
|
+
Async non-streaming wrapper for `generate()`.
|
|
587
|
+
|
|
588
|
+
Implementation: run sync HTTP calls in a worker thread via `asyncio.to_thread`.
|
|
589
|
+
"""
|
|
590
|
+
out = await asyncio.to_thread(self.generate, request, stream=False)
|
|
591
|
+
if isinstance(out, GenerateResponse):
|
|
592
|
+
return out
|
|
593
|
+
raise not_supported_error("provider returned stream response")
|
|
594
|
+
|
|
595
|
+
def _adapter(self, provider: str):
|
|
596
|
+
provider = _normalize_provider(provider)
|
|
597
|
+
if provider == "openai":
|
|
598
|
+
if self._openai is None:
|
|
599
|
+
raise invalid_request_error(
|
|
600
|
+
"NOUS_GENAI_OPENAI_API_KEY/OPENAI_API_KEY not configured"
|
|
601
|
+
)
|
|
602
|
+
return self._openai
|
|
603
|
+
if provider == "google":
|
|
604
|
+
if self._gemini is None:
|
|
605
|
+
raise invalid_request_error(
|
|
606
|
+
"NOUS_GENAI_GOOGLE_API_KEY/GOOGLE_API_KEY not configured"
|
|
607
|
+
)
|
|
608
|
+
return self._gemini
|
|
609
|
+
if provider == "anthropic":
|
|
610
|
+
if self._anthropic is None:
|
|
611
|
+
raise invalid_request_error(
|
|
612
|
+
"NOUS_GENAI_ANTHROPIC_API_KEY/ANTHROPIC_API_KEY not configured"
|
|
613
|
+
)
|
|
614
|
+
return self._anthropic
|
|
615
|
+
if provider == "aliyun":
|
|
616
|
+
if self._aliyun is None:
|
|
617
|
+
raise invalid_request_error(
|
|
618
|
+
"NOUS_GENAI_ALIYUN_API_KEY/ALIYUN_API_KEY not configured"
|
|
619
|
+
)
|
|
620
|
+
return self._aliyun
|
|
621
|
+
if provider == "volcengine":
|
|
622
|
+
if self._volcengine is None:
|
|
623
|
+
raise invalid_request_error(
|
|
624
|
+
"NOUS_GENAI_VOLCENGINE_API_KEY/VOLCENGINE_API_KEY not configured"
|
|
625
|
+
)
|
|
626
|
+
return self._volcengine
|
|
627
|
+
if provider == "tuzi-web":
|
|
628
|
+
if self._tuzi_web is None:
|
|
629
|
+
raise invalid_request_error(
|
|
630
|
+
"NOUS_GENAI_TUZI_WEB_API_KEY/TUZI_WEB_API_KEY not configured"
|
|
631
|
+
)
|
|
632
|
+
return self._tuzi_web
|
|
633
|
+
if provider == "tuzi-openai":
|
|
634
|
+
if self._tuzi_openai is None:
|
|
635
|
+
raise invalid_request_error(
|
|
636
|
+
"NOUS_GENAI_TUZI_OPENAI_API_KEY/TUZI_OPENAI_API_KEY not configured"
|
|
637
|
+
)
|
|
638
|
+
return self._tuzi_openai
|
|
639
|
+
if provider == "tuzi-google":
|
|
640
|
+
if self._tuzi_google is None:
|
|
641
|
+
raise invalid_request_error(
|
|
642
|
+
"NOUS_GENAI_TUZI_GOOGLE_API_KEY/TUZI_GOOGLE_API_KEY not configured"
|
|
643
|
+
)
|
|
644
|
+
return self._tuzi_google
|
|
645
|
+
if provider == "tuzi-anthropic":
|
|
646
|
+
if self._tuzi_anthropic is None:
|
|
647
|
+
raise invalid_request_error(
|
|
648
|
+
"NOUS_GENAI_TUZI_ANTHROPIC_API_KEY/TUZI_ANTHROPIC_API_KEY not configured"
|
|
649
|
+
)
|
|
650
|
+
return self._tuzi_anthropic
|
|
651
|
+
raise invalid_request_error(f"unknown provider: {provider}")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _normalize_provider(provider: str) -> str:
|
|
655
|
+
p = provider.strip().lower()
|
|
656
|
+
if p == "gemini":
|
|
657
|
+
return "google"
|
|
658
|
+
if p in {"volc", "ark"}:
|
|
659
|
+
return "volcengine"
|
|
660
|
+
return p
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _split_model(model: str) -> tuple[str, str]:
|
|
664
|
+
if ":" not in model:
|
|
665
|
+
raise invalid_request_error('model must be "{provider}:{model_id}"')
|
|
666
|
+
provider, model_id = model.split(":", 1)
|
|
667
|
+
return provider, model_id
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _env_int(name: str, default: int) -> int:
|
|
671
|
+
raw = os.environ.get(name)
|
|
672
|
+
if raw is None:
|
|
673
|
+
return default
|
|
674
|
+
try:
|
|
675
|
+
value = int(raw)
|
|
676
|
+
except ValueError:
|
|
677
|
+
return default
|
|
678
|
+
return value
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _is_mcp_transport_marker(value: str) -> bool:
|
|
682
|
+
return value in {"mcp", "sse", "streamable", "streamable-http", "streamable_http"}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _validate_mcp_wire_request(request: GenerateRequest) -> None:
|
|
686
|
+
for msg in request.input:
|
|
687
|
+
for part in msg.content:
|
|
688
|
+
source = part.source
|
|
689
|
+
if source is None:
|
|
690
|
+
continue
|
|
691
|
+
kind = getattr(source, "kind", None)
|
|
692
|
+
if kind == "path":
|
|
693
|
+
raise invalid_request_error(
|
|
694
|
+
"MCP transport does not support local file sources; use bytes(encoding=base64)/url/ref"
|
|
695
|
+
)
|
|
696
|
+
if kind == "bytes":
|
|
697
|
+
enc = getattr(source, "encoding", None)
|
|
698
|
+
data = getattr(source, "data", None)
|
|
699
|
+
if enc == "base64" and isinstance(data, str):
|
|
700
|
+
continue
|
|
701
|
+
raise invalid_request_error(
|
|
702
|
+
"MCP transport does not support raw bytes; use bytes(encoding=base64)/url/ref"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _normalize_output_text_json_schema(request: GenerateRequest) -> GenerateRequest:
|
|
707
|
+
spec = request.output.text
|
|
708
|
+
if spec is None:
|
|
709
|
+
return request
|
|
710
|
+
schema = spec.json_schema
|
|
711
|
+
if schema is None:
|
|
712
|
+
return request
|
|
713
|
+
if isinstance(schema, dict):
|
|
714
|
+
reject_gemini_response_schema_dict(schema)
|
|
715
|
+
return request
|
|
716
|
+
coerced = normalize_json_schema(schema)
|
|
717
|
+
return replace(
|
|
718
|
+
request, output=replace(request.output, text=replace(spec, json_schema=coerced))
|
|
719
|
+
)
|