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.
Files changed (45) hide show
  1. nous/__init__.py +3 -0
  2. nous/genai/__init__.py +56 -0
  3. nous/genai/__main__.py +3 -0
  4. nous/genai/_internal/__init__.py +1 -0
  5. nous/genai/_internal/capability_rules.py +476 -0
  6. nous/genai/_internal/config.py +102 -0
  7. nous/genai/_internal/errors.py +63 -0
  8. nous/genai/_internal/http.py +951 -0
  9. nous/genai/_internal/json_schema.py +54 -0
  10. nous/genai/cli.py +1316 -0
  11. nous/genai/client.py +719 -0
  12. nous/genai/mcp_cli.py +275 -0
  13. nous/genai/mcp_server.py +1080 -0
  14. nous/genai/providers/__init__.py +15 -0
  15. nous/genai/providers/aliyun.py +535 -0
  16. nous/genai/providers/anthropic.py +483 -0
  17. nous/genai/providers/gemini.py +1606 -0
  18. nous/genai/providers/openai.py +1909 -0
  19. nous/genai/providers/tuzi.py +1158 -0
  20. nous/genai/providers/volcengine.py +273 -0
  21. nous/genai/reference/__init__.py +17 -0
  22. nous/genai/reference/catalog.py +206 -0
  23. nous/genai/reference/mappings.py +467 -0
  24. nous/genai/reference/mode_overrides.py +26 -0
  25. nous/genai/reference/model_catalog.py +82 -0
  26. nous/genai/reference/model_catalog_data/__init__.py +1 -0
  27. nous/genai/reference/model_catalog_data/aliyun.py +98 -0
  28. nous/genai/reference/model_catalog_data/anthropic.py +10 -0
  29. nous/genai/reference/model_catalog_data/google.py +45 -0
  30. nous/genai/reference/model_catalog_data/openai.py +44 -0
  31. nous/genai/reference/model_catalog_data/tuzi_anthropic.py +21 -0
  32. nous/genai/reference/model_catalog_data/tuzi_google.py +19 -0
  33. nous/genai/reference/model_catalog_data/tuzi_openai.py +75 -0
  34. nous/genai/reference/model_catalog_data/tuzi_web.py +136 -0
  35. nous/genai/reference/model_catalog_data/volcengine.py +107 -0
  36. nous/genai/tools/__init__.py +13 -0
  37. nous/genai/tools/output_parser.py +119 -0
  38. nous/genai/types.py +416 -0
  39. nous/py.typed +1 -0
  40. nous_genai-0.1.0.dist-info/METADATA +200 -0
  41. nous_genai-0.1.0.dist-info/RECORD +45 -0
  42. nous_genai-0.1.0.dist-info/WHEEL +5 -0
  43. nous_genai-0.1.0.dist-info/entry_points.txt +4 -0
  44. nous_genai-0.1.0.dist-info/licenses/LICENSE +190 -0
  45. 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
+ )