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
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterator
6
+ from uuid import uuid4
7
+
8
+ from .._internal.errors import (
9
+ invalid_request_error,
10
+ not_supported_error,
11
+ provider_error,
12
+ )
13
+ from .._internal.http import request_json
14
+ from ..types import Capability, GenerateEvent, GenerateRequest, GenerateResponse
15
+ from ..types import JobInfo, Message, Part, PartSourceUrl
16
+ from .openai import OpenAIAdapter
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class VolcengineAdapter:
21
+ """
22
+ Volcengine Ark (Doubao).
23
+
24
+ Supported in this SDK:
25
+ - chat (text/image -> text), stream supported
26
+ - embeddings (text -> embedding) for text embedding models
27
+ - image generation (text -> image) for Seedream models
28
+ - video generation (text -> video) for Seedance models via content generation tasks
29
+ """
30
+
31
+ openai: OpenAIAdapter
32
+
33
+ def capabilities(self, model_id: str) -> Capability:
34
+ if _is_text_embedding_model(model_id):
35
+ return Capability(
36
+ input_modalities={"text"},
37
+ output_modalities={"embedding"},
38
+ supports_stream=False,
39
+ supports_job=False,
40
+ supports_tools=False,
41
+ supports_json_schema=False,
42
+ )
43
+ if _is_seedream_model(model_id):
44
+ return Capability(
45
+ input_modalities={"text"},
46
+ output_modalities={"image"},
47
+ supports_stream=False,
48
+ supports_job=False,
49
+ supports_tools=False,
50
+ supports_json_schema=False,
51
+ )
52
+ if _is_seedance_video_model(model_id):
53
+ return Capability(
54
+ input_modalities={"text"},
55
+ output_modalities={"video"},
56
+ supports_stream=False,
57
+ supports_job=True,
58
+ supports_tools=False,
59
+ supports_json_schema=False,
60
+ )
61
+ return Capability(
62
+ input_modalities={"text", "image"},
63
+ output_modalities={"text"},
64
+ supports_stream=True,
65
+ supports_job=False,
66
+ supports_tools=True,
67
+ supports_json_schema=True,
68
+ )
69
+
70
+ def list_models(self, *, timeout_ms: int | None = None) -> list[str]:
71
+ return self.openai.list_models(timeout_ms=timeout_ms)
72
+
73
+ def generate(
74
+ self, request: GenerateRequest, *, stream: bool
75
+ ) -> GenerateResponse | Iterator[GenerateEvent]:
76
+ modalities = set(request.output.modalities)
77
+ model_id = request.model_id()
78
+
79
+ if modalities == {"embedding"}:
80
+ if stream:
81
+ raise not_supported_error(
82
+ "Volcengine embeddings do not support streaming"
83
+ )
84
+ if not _is_text_embedding_model(model_id):
85
+ raise not_supported_error(
86
+ "Volcengine embedding requires a text embedding model"
87
+ )
88
+ return self.openai.generate(request, stream=False)
89
+
90
+ if modalities == {"image"}:
91
+ if stream:
92
+ raise not_supported_error(
93
+ "Volcengine image generation does not support streaming"
94
+ )
95
+ if not _is_seedream_model(model_id):
96
+ raise not_supported_error(
97
+ "Volcengine image generation requires a Seedream model"
98
+ )
99
+ return self.openai.generate(request, stream=False)
100
+
101
+ if modalities == {"video"}:
102
+ if stream:
103
+ raise not_supported_error(
104
+ "Volcengine video generation does not support streaming"
105
+ )
106
+ return self._video(request, model_id=model_id)
107
+
108
+ if modalities != {"text"}:
109
+ raise not_supported_error(
110
+ "Volcengine only supports text chat, embeddings, Seedream images, and Seedance video in this SDK"
111
+ )
112
+ if _is_text_embedding_model(model_id):
113
+ raise not_supported_error(
114
+ "Volcengine embedding models must be called with output.modalities=['embedding']"
115
+ )
116
+ if _is_seedream_model(model_id):
117
+ raise not_supported_error(
118
+ "Volcengine Seedream models must be called with output.modalities=['image']"
119
+ )
120
+ if _is_seedance_video_model(model_id):
121
+ raise not_supported_error(
122
+ "Volcengine Seedance models must be called with output.modalities=['video']"
123
+ )
124
+ if _has_audio_input(request):
125
+ raise not_supported_error(
126
+ "Volcengine chat input does not support audio in this SDK"
127
+ )
128
+ return self.openai.generate(request, stream=stream)
129
+
130
+ def _video(self, request: GenerateRequest, *, model_id: str) -> GenerateResponse:
131
+ if not _is_seedance_video_model(model_id):
132
+ raise not_supported_error(
133
+ 'Volcengine video generation requires model like "volcengine:doubao-seedance-1-0-lite-t2v-250428"'
134
+ )
135
+
136
+ prompt = _single_text_prompt(request)
137
+ body: dict[str, Any] = {
138
+ "model": model_id,
139
+ "content": [{"type": "text", "text": prompt}],
140
+ }
141
+
142
+ opts = request.provider_options.get("volcengine")
143
+ if isinstance(opts, dict):
144
+ if "model" in opts and opts["model"] != model_id:
145
+ raise invalid_request_error("provider_options cannot override model")
146
+ body.update({k: v for k, v in opts.items() if k != "model"})
147
+
148
+ budget_ms = (
149
+ 120_000 if request.params.timeout_ms is None else request.params.timeout_ms
150
+ )
151
+ deadline = time.time() + max(1, budget_ms) / 1000.0
152
+ obj = request_json(
153
+ method="POST",
154
+ url=f"{self.openai.base_url}/contents/generations/tasks",
155
+ headers=_headers(self.openai.api_key, request=request),
156
+ json_body=body,
157
+ timeout_ms=min(30_000, max(1, budget_ms)),
158
+ proxy_url=self.openai.proxy_url,
159
+ )
160
+ task_id = obj.get("id")
161
+ if not isinstance(task_id, str) or not task_id:
162
+ raise provider_error("volcengine video response missing task id")
163
+
164
+ if not request.wait:
165
+ return GenerateResponse(
166
+ id=f"sdk_{uuid4().hex}",
167
+ provider="volcengine",
168
+ model=f"volcengine:{model_id}",
169
+ status="running",
170
+ job=JobInfo(job_id=task_id, poll_after_ms=1_000),
171
+ )
172
+
173
+ final = _wait_task_done(
174
+ base_url=self.openai.base_url,
175
+ api_key=self.openai.api_key,
176
+ task_id=task_id,
177
+ deadline=deadline,
178
+ proxy_url=self.openai.proxy_url,
179
+ )
180
+ status = final.get("status")
181
+ if status != "succeeded":
182
+ if status == "failed":
183
+ raise provider_error(f"volcengine video generation failed: {final}")
184
+ return GenerateResponse(
185
+ id=f"sdk_{uuid4().hex}",
186
+ provider="volcengine",
187
+ model=f"volcengine:{model_id}",
188
+ status="running",
189
+ job=JobInfo(job_id=task_id, poll_after_ms=1_000),
190
+ )
191
+
192
+ content = final.get("content")
193
+ video_url = content.get("video_url") if isinstance(content, dict) else None
194
+ if not isinstance(video_url, str) or not video_url:
195
+ raise provider_error("volcengine video task missing video_url")
196
+ part = Part(
197
+ type="video", mime_type="video/mp4", source=PartSourceUrl(url=video_url)
198
+ )
199
+ return GenerateResponse(
200
+ id=f"sdk_{uuid4().hex}",
201
+ provider="volcengine",
202
+ model=f"volcengine:{model_id}",
203
+ status="completed",
204
+ output=[Message(role="assistant", content=[part])],
205
+ usage=None,
206
+ )
207
+
208
+
209
+ def _is_text_embedding_model(model_id: str) -> bool:
210
+ model_id = model_id.lower()
211
+ return "embedding" in model_id and "text" in model_id
212
+
213
+
214
+ def _is_seedream_model(model_id: str) -> bool:
215
+ return "seedream" in model_id.lower()
216
+
217
+
218
+ def _is_seedance_video_model(model_id: str) -> bool:
219
+ mid = model_id.lower()
220
+ return "seedance" in mid and ("t2v" in mid or "i2v" in mid)
221
+
222
+
223
+ def _single_text_prompt(request: GenerateRequest) -> str:
224
+ texts: list[str] = []
225
+ for m in request.input:
226
+ for p in m.content:
227
+ if p.type != "text":
228
+ raise invalid_request_error(
229
+ "this operation requires exactly one text part"
230
+ )
231
+ t = p.require_text().strip()
232
+ if t:
233
+ texts.append(t)
234
+ if len(texts) != 1:
235
+ raise invalid_request_error("this operation requires exactly one text part")
236
+ return texts[0]
237
+
238
+
239
+ def _headers(api_key: str, *, request: GenerateRequest | None = None) -> dict[str, str]:
240
+ headers = {"Authorization": f"Bearer {api_key}"}
241
+ if request and request.params.idempotency_key:
242
+ headers["Idempotency-Key"] = request.params.idempotency_key
243
+ return headers
244
+
245
+
246
+ def _wait_task_done(
247
+ *, base_url: str, api_key: str, task_id: str, deadline: float, proxy_url: str | None
248
+ ) -> dict[str, Any]:
249
+ url = f"{base_url}/contents/generations/tasks/{task_id}"
250
+ while True:
251
+ remaining_ms = int((deadline - time.time()) * 1000)
252
+ if remaining_ms <= 0:
253
+ break
254
+ obj = request_json(
255
+ method="GET",
256
+ url=url,
257
+ headers=_headers(api_key),
258
+ timeout_ms=min(30_000, remaining_ms),
259
+ proxy_url=proxy_url,
260
+ )
261
+ st = obj.get("status")
262
+ if st in {"succeeded", "failed", "cancelled"}:
263
+ return obj
264
+ time.sleep(1.0)
265
+ return {"id": task_id, "status": "running"}
266
+
267
+
268
+ def _has_audio_input(request: GenerateRequest) -> bool:
269
+ for m in request.input:
270
+ for p in m.content:
271
+ if p.type == "audio":
272
+ return True
273
+ return False
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from .catalog import (
4
+ get_model_catalog,
5
+ get_sdk_supported_models,
6
+ get_sdk_supported_models_for_provider,
7
+ get_supported_providers,
8
+ )
9
+ from .mappings import get_parameter_mappings
10
+
11
+ __all__ = [
12
+ "get_model_catalog",
13
+ "get_parameter_mappings",
14
+ "get_sdk_supported_models",
15
+ "get_sdk_supported_models_for_provider",
16
+ "get_supported_providers",
17
+ ]
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any, Literal
5
+
6
+ from ..types import Capability
7
+
8
+ from .model_catalog import MODEL_CATALOG
9
+ from .mode_overrides import CAPABILITY_OVERRIDES
10
+
11
+
12
+ def get_model_catalog() -> dict[str, list[str]]:
13
+ return copy.deepcopy(MODEL_CATALOG)
14
+
15
+
16
+ def get_supported_providers() -> list[str]:
17
+ return list(MODEL_CATALOG.keys())
18
+
19
+
20
+ def get_sdk_supported_models() -> list[dict[str, Any]]:
21
+ """
22
+ Return a JSON-friendly table of all curated models and their SDK-level capabilities.
23
+ """
24
+ out: list[dict[str, Any]] = []
25
+ for provider, model_ids in MODEL_CATALOG.items():
26
+ protocol = _default_protocol(provider)
27
+ for model_id in model_ids:
28
+ cap = _capability_for(
29
+ provider=provider, protocol=protocol, model_id=model_id
30
+ )
31
+ cap = _apply_capability_overrides(
32
+ provider=provider, model_id=model_id, cap=cap
33
+ )
34
+ category = _category_for_capability(cap)
35
+ modes = _modes_for(cap)
36
+ notes: list[str] = []
37
+ if cap.supports_job:
38
+ notes.append("可能返回 running(job),需轮询/等待")
39
+ out.append(
40
+ {
41
+ "provider": provider,
42
+ "model_id": model_id,
43
+ "model": f"{provider}:{model_id}",
44
+ "category": category,
45
+ "protocol": protocol,
46
+ "modes": modes,
47
+ "input_modalities": sorted(cap.input_modalities),
48
+ "output_modalities": sorted(cap.output_modalities),
49
+ "supports_job": cap.supports_job,
50
+ "notes": notes,
51
+ }
52
+ )
53
+ return out
54
+
55
+
56
+ def get_sdk_supported_models_for_provider(provider: str) -> list[dict[str, Any]]:
57
+ """
58
+ Return SDK-curated supported models for a single provider (same rows as `get_sdk_supported_models()`).
59
+
60
+ Note: `provider` must match keys used in `MODEL_CATALOG` (e.g. "google", not "gemini").
61
+ """
62
+ p = provider.strip().lower()
63
+ if not p:
64
+ return []
65
+ return [m for m in get_sdk_supported_models() if m.get("provider") == p]
66
+
67
+
68
+ def _default_protocol(provider: str) -> str:
69
+ if provider in {"google", "tuzi-google"}:
70
+ return "gemini"
71
+ if provider in {"anthropic", "tuzi-anthropic"}:
72
+ return "messages"
73
+ if provider == "tuzi-web":
74
+ return "multi"
75
+ return "chat_completions"
76
+
77
+
78
+ def _category_for_capability(cap: Capability) -> str:
79
+ out = set(cap.output_modalities)
80
+ if out == {"embedding"}:
81
+ return "embedding"
82
+ if out == {"image"}:
83
+ return "image"
84
+ if out == {"video"}:
85
+ return "video"
86
+ if out == {"audio"}:
87
+ return "audio"
88
+ if (
89
+ out == {"text"}
90
+ and "audio" in set(cap.input_modalities)
91
+ and not cap.supports_stream
92
+ ):
93
+ return "transcription"
94
+ return "chat"
95
+
96
+
97
+ def _capability_for(*, provider: str, protocol: str, model_id: str) -> Capability:
98
+ if provider in {"openai", "tuzi-openai"}:
99
+ from ..providers import OpenAIAdapter
100
+
101
+ return OpenAIAdapter(
102
+ api_key="__demo__", provider_name=provider, chat_api=protocol
103
+ ).capabilities(model_id)
104
+
105
+ if provider in {"google", "tuzi-google"}:
106
+ from ..providers import GeminiAdapter
107
+
108
+ gemini_auth_mode: Literal["bearer", "query_key"] = (
109
+ "bearer" if provider.startswith("tuzi-") else "query_key"
110
+ )
111
+ return GeminiAdapter(
112
+ api_key="__demo__", provider_name=provider, auth_mode=gemini_auth_mode
113
+ ).capabilities(model_id)
114
+
115
+ if provider in {"anthropic", "tuzi-anthropic"}:
116
+ from ..providers import AnthropicAdapter
117
+
118
+ anthropic_auth_mode: Literal["bearer", "x-api-key"] = (
119
+ "bearer" if provider.startswith("tuzi-") else "x-api-key"
120
+ )
121
+ return AnthropicAdapter(
122
+ api_key="__demo__", provider_name=provider, auth_mode=anthropic_auth_mode
123
+ ).capabilities(model_id)
124
+
125
+ if provider == "volcengine":
126
+ from ..providers import OpenAIAdapter, VolcengineAdapter
127
+
128
+ volc = VolcengineAdapter(
129
+ openai=OpenAIAdapter(
130
+ api_key="__demo__",
131
+ provider_name="volcengine",
132
+ chat_api="chat_completions",
133
+ )
134
+ )
135
+ return volc.capabilities(model_id)
136
+
137
+ if provider == "aliyun":
138
+ from ..providers import AliyunAdapter, OpenAIAdapter
139
+
140
+ aliyun = AliyunAdapter(
141
+ openai=OpenAIAdapter(
142
+ api_key="__demo__", provider_name="aliyun", chat_api="chat_completions"
143
+ )
144
+ )
145
+ return aliyun.capabilities(model_id)
146
+
147
+ if provider == "tuzi-web":
148
+ from ..providers import (
149
+ AnthropicAdapter,
150
+ GeminiAdapter,
151
+ OpenAIAdapter,
152
+ TuziAdapter,
153
+ )
154
+
155
+ return TuziAdapter(
156
+ openai=OpenAIAdapter(
157
+ api_key="__demo__", provider_name=provider, chat_api="chat_completions"
158
+ ),
159
+ gemini=GeminiAdapter(
160
+ api_key="__demo__",
161
+ base_url="https://api.tu-zi.com",
162
+ provider_name=provider,
163
+ auth_mode="bearer",
164
+ supports_file_upload=False,
165
+ ),
166
+ anthropic=AnthropicAdapter(
167
+ api_key="__demo__", provider_name=provider, auth_mode="bearer"
168
+ ),
169
+ ).capabilities(model_id)
170
+
171
+ raise ValueError(f"unknown provider: {provider}")
172
+
173
+
174
+ def _apply_capability_overrides(
175
+ *, provider: str, model_id: str, cap: Capability
176
+ ) -> Capability:
177
+ overrides = CAPABILITY_OVERRIDES.get(provider)
178
+ if not overrides:
179
+ return cap
180
+ override = overrides.get(model_id)
181
+ if not override:
182
+ return cap
183
+ supports_stream = cap.supports_stream
184
+ if "supports_stream" in override:
185
+ supports_stream = override["supports_stream"]
186
+ supports_job = cap.supports_job
187
+ if "supports_job" in override:
188
+ supports_job = override["supports_job"]
189
+ if supports_stream == cap.supports_stream and supports_job == cap.supports_job:
190
+ return cap
191
+ return Capability(
192
+ input_modalities=set(cap.input_modalities),
193
+ output_modalities=set(cap.output_modalities),
194
+ supports_stream=supports_stream,
195
+ supports_job=supports_job,
196
+ )
197
+
198
+
199
+ def _modes_for(cap: Capability) -> list[str]:
200
+ modes: list[str] = ["sync"]
201
+ if cap.supports_stream:
202
+ modes.append("stream")
203
+ if cap.supports_job:
204
+ modes.append("job")
205
+ modes.append("async")
206
+ return modes