u-transcript-max 0.1.0a1__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 (77) hide show
  1. u_transcript_max-0.1.0a1.dist-info/METADATA +67 -0
  2. u_transcript_max-0.1.0a1.dist-info/RECORD +77 -0
  3. u_transcript_max-0.1.0a1.dist-info/WHEEL +4 -0
  4. u_transcript_max-0.1.0a1.dist-info/entry_points.txt +2 -0
  5. u_transcript_max-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  6. utmax/__init__.py +651 -0
  7. utmax/_version.py +1 -0
  8. utmax/adapters/__init__.py +1 -0
  9. utmax/adapters/downloader.py +479 -0
  10. utmax/adapters/ffmpeg.py +174 -0
  11. utmax/adapters/files.py +130 -0
  12. utmax/adapters/http.py +285 -0
  13. utmax/adapters/innertube.py +207 -0
  14. utmax/adapters/providers/__init__.py +45 -0
  15. utmax/adapters/providers/base.py +177 -0
  16. utmax/adapters/providers/claude.py +108 -0
  17. utmax/adapters/providers/gemini.py +133 -0
  18. utmax/adapters/providers/openai.py +169 -0
  19. utmax/adapters/providers/openrouter.py +59 -0
  20. utmax/adapters/watch_page.py +73 -0
  21. utmax/client.py +300 -0
  22. utmax/compat/__init__.py +134 -0
  23. utmax/compat/_api.py +203 -0
  24. utmax/compat/_bridge.py +212 -0
  25. utmax/compat/_errors.py +300 -0
  26. utmax/compat/_settings.py +13 -0
  27. utmax/compat/_transcripts.py +440 -0
  28. utmax/compat/formatters.py +241 -0
  29. utmax/compat/proxies.py +129 -0
  30. utmax/core/__init__.py +6 -0
  31. utmax/core/bilingual.py +107 -0
  32. utmax/core/browse.py +397 -0
  33. utmax/core/captions.py +251 -0
  34. utmax/core/clients.py +113 -0
  35. utmax/core/downloads.py +165 -0
  36. utmax/core/filenames.py +298 -0
  37. utmax/core/formats.py +198 -0
  38. utmax/core/ids.py +210 -0
  39. utmax/core/languages.py +371 -0
  40. utmax/core/media/__init__.py +7 -0
  41. utmax/core/media/boxes.py +266 -0
  42. utmax/core/media/fmp4.py +394 -0
  43. utmax/core/media/moov.py +299 -0
  44. utmax/core/media/mux.py +423 -0
  45. utmax/core/media/progressive.py +267 -0
  46. utmax/core/media/tables.py +129 -0
  47. utmax/core/media/tx3g.py +191 -0
  48. utmax/core/playability.py +62 -0
  49. utmax/core/player.py +90 -0
  50. utmax/core/retry.py +45 -0
  51. utmax/core/segmentation.py +104 -0
  52. utmax/core/selection.py +92 -0
  53. utmax/core/streams.py +264 -0
  54. utmax/core/translate/__init__.py +1 -0
  55. utmax/core/translate/batching.py +157 -0
  56. utmax/core/translate/data/protocol.json +17 -0
  57. utmax/core/translate/data/request.schema.json +49 -0
  58. utmax/core/translate/data/response.schema.json +19 -0
  59. utmax/core/translate/data/system_prompt.txt +21 -0
  60. utmax/core/translate/protocol.py +101 -0
  61. utmax/core/translate/spec.py +49 -0
  62. utmax/core/ytdata.py +32 -0
  63. utmax/errors.py +523 -0
  64. utmax/mcp/__init__.py +30 -0
  65. utmax/mcp/__main__.py +6 -0
  66. utmax/mcp/config.py +56 -0
  67. utmax/mcp/server.py +626 -0
  68. utmax/models.py +499 -0
  69. utmax/providers.py +36 -0
  70. utmax/py.typed +0 -0
  71. utmax/services/__init__.py +1 -0
  72. utmax/services/bulk.py +454 -0
  73. utmax/services/collections.py +183 -0
  74. utmax/services/download.py +375 -0
  75. utmax/services/transcripts.py +87 -0
  76. utmax/services/translation.py +211 -0
  77. utmax/transport.py +81 -0
@@ -0,0 +1,177 @@
1
+ """What every translator provides, plus the plumbing shared by the built-in providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import re
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Mapping
9
+ from types import ModuleType
10
+ from typing import Any, ClassVar, TypedDict, Unpack
11
+
12
+ from utmax.errors import (
13
+ InvalidOption,
14
+ ProviderAuthError,
15
+ ProviderError,
16
+ ProviderNotInstalled,
17
+ ProviderRateLimited,
18
+ TranslationError,
19
+ )
20
+
21
+ __all__ = [
22
+ "EngineOptions",
23
+ "SDKTranslator",
24
+ "Translator",
25
+ "positive_int",
26
+ "redact_secrets",
27
+ "stainless_error",
28
+ ]
29
+
30
+ _SECRET = re.compile(r"\b(?:sk-[A-Za-z0-9_*-]{6,}|AIza[0-9A-Za-z_-]{20,})")
31
+
32
+
33
+ class EngineOptions(TypedDict, total=False):
34
+ """The engine options every translator accepts as keyword arguments."""
35
+
36
+ batch_chars: int
37
+ batch_items: int
38
+ context_items: int
39
+ concurrency: int
40
+ max_attempts: int
41
+
42
+
43
+ def positive_int(option: str, value: object, *, minimum: int = 1) -> int:
44
+ """``value`` when it is an integer of at least ``minimum``, else :class:`InvalidOption`."""
45
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
46
+ raise InvalidOption(f"{option} must be an integer of at least {minimum}, not {value!r}.")
47
+ return value
48
+
49
+
50
+ def redact_secrets(text: str) -> str:
51
+ """``text`` with anything shaped like an API key replaced by ``[redacted]``."""
52
+ return _SECRET.sub("[redacted]", text)
53
+
54
+
55
+ class Translator(ABC):
56
+ """Translates batches of subtitle cues by asking an AI model for JSON.
57
+
58
+ Use :func:`utmax.translator` to get one of the built-in translators from a
59
+ ``"provider=model-id"`` string, or subclass this to plug in any other model: implement
60
+ :attr:`name` and :meth:`generate_json`, then pass an instance as ``model=`` to
61
+ :func:`utmax.translate`.
62
+
63
+ The engine options control how :func:`utmax.translate` splits the work:
64
+
65
+ Args:
66
+ batch_chars: most characters of cue text in one request.
67
+ batch_items: most cues in one request.
68
+ context_items: neighbouring source cues sent on each side as read-only context.
69
+ concurrency: requests running at the same time.
70
+ max_attempts: tries per batch before it is split in half.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ batch_chars: int = 4000,
77
+ batch_items: int = 50,
78
+ context_items: int = 3,
79
+ concurrency: int = 4,
80
+ max_attempts: int = 2,
81
+ ) -> None:
82
+ self.batch_chars = positive_int("batch_chars", batch_chars)
83
+ self.batch_items = positive_int("batch_items", batch_items)
84
+ self.context_items = positive_int("context_items", context_items, minimum=0)
85
+ self.concurrency = positive_int("concurrency", concurrency)
86
+ self.max_attempts = positive_int("max_attempts", max_attempts)
87
+
88
+ @property
89
+ @abstractmethod
90
+ def name(self) -> str:
91
+ """``"provider=model-id"``, for example ``"claude=claude-opus-5"``.
92
+
93
+ Stored as ``Transcript.translator`` on every translation.
94
+ """
95
+
96
+ @property
97
+ def provider(self) -> str:
98
+ """The provider part of :attr:`name`, used in error reports."""
99
+ return self.name.partition("=")[0]
100
+
101
+ @abstractmethod
102
+ def generate_json(self, system: str, prompt: str, schema: Mapping[str, Any]) -> str:
103
+ """Send one request and return the model's raw answer, which should be JSON.
104
+
105
+ Args:
106
+ system: the system prompt.
107
+ prompt: the user message, a JSON document describing one batch.
108
+ schema: the JSON schema the answer must match.
109
+
110
+ Raise :class:`utmax.providers.InvalidResponse` for an answer that cannot be used, such
111
+ as one cut off by a token limit (the engine retries, then splits the batch), and a
112
+ :class:`utmax.errors.TranslationError` subclass when the provider fails (never retried).
113
+ """
114
+
115
+ def __repr__(self) -> str:
116
+ return f"{type(self).__name__}({self.name!r})"
117
+
118
+
119
+ class SDKTranslator(Translator):
120
+ """Base of the built-in translators: a provider id, a model id and a lazily imported SDK.
121
+
122
+ API keys are handed straight to the SDK client and never stored, logged or shown by utmax.
123
+ """
124
+
125
+ PROVIDER: ClassVar[str]
126
+ EXTRA: ClassVar[str]
127
+
128
+ def __init__(self, model: str, **engine: Unpack[EngineOptions]) -> None:
129
+ super().__init__(**engine)
130
+ if not isinstance(model, str) or not model.strip():
131
+ raise InvalidOption(f"The {self.PROVIDER} model id must not be empty, got {model!r}.")
132
+ self.model = model.strip()
133
+
134
+ @property
135
+ def name(self) -> str:
136
+ return f"{self.PROVIDER}={self.model}"
137
+
138
+ def import_sdk(self, module: str) -> ModuleType:
139
+ """Import ``module`` (the provider's SDK), or say which extra installs it."""
140
+ try:
141
+ return importlib.import_module(module)
142
+ except ImportError as error:
143
+ command = f'pip install "u-transcript-max[{self.EXTRA}]"'
144
+ raise ProviderNotInstalled(
145
+ f"The {self.PROVIDER} translator needs the {module!r} package: {command}",
146
+ provider=self.PROVIDER,
147
+ extra=self.EXTRA,
148
+ suggestion=f"Install the {self.EXTRA} extra with: {command}",
149
+ ) from error
150
+
151
+
152
+ def stainless_error(
153
+ sdk: Any, error: BaseException, *, provider: str, service: str
154
+ ) -> TranslationError | None:
155
+ """Map an exception of the Anthropic or OpenAI SDK (both generated by Stainless).
156
+
157
+ ``service`` names the server in the message ("Anthropic", "the server at ..."). Returns
158
+ ``None`` for exceptions that are not API failures, which callers re-raise.
159
+ """
160
+ detail = redact_secrets(str(getattr(error, "message", "") or error))
161
+ status = getattr(error, "status_code", None)
162
+ if isinstance(error, (sdk.AuthenticationError, sdk.PermissionDeniedError)):
163
+ return ProviderAuthError(
164
+ f"Credentials rejected by {service} (HTTP {status}): {detail}", provider=provider
165
+ )
166
+ if isinstance(error, sdk.RateLimitError):
167
+ return ProviderRateLimited(
168
+ f"Still rate-limited by {service} after the SDK's retries: {detail}",
169
+ provider=provider,
170
+ )
171
+ if isinstance(error, sdk.APIStatusError):
172
+ return ProviderError(
173
+ f"HTTP {status} from {service}: {detail}", provider=provider, status_code=status
174
+ )
175
+ if isinstance(error, sdk.APIConnectionError):
176
+ return ProviderError(f"Could not reach {service}: {detail}", provider=provider)
177
+ return None
@@ -0,0 +1,108 @@
1
+ """Claude through the official ``anthropic`` SDK, with structured JSON output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any, Unpack
8
+
9
+ from utmax.adapters.providers.base import (
10
+ EngineOptions,
11
+ SDKTranslator,
12
+ positive_int,
13
+ redact_secrets,
14
+ stainless_error,
15
+ )
16
+ from utmax.core.translate.batching import InvalidResponse
17
+ from utmax.errors import InvalidOption, ProviderAuthError, TranslationRefused
18
+
19
+ __all__ = ["ClaudeTranslator"]
20
+
21
+ log = logging.getLogger("utmax.translate")
22
+
23
+ _EFFORTS = ("low", "medium", "high", "xhigh", "max")
24
+ _CUT_OFF = frozenset({"max_tokens", "model_context_window_exceeded"})
25
+
26
+
27
+ class ClaudeTranslator(SDKTranslator):
28
+ """Translates with Anthropic's Claude models (``pip install "u-transcript-max[claude]"``).
29
+
30
+ Args:
31
+ model: a Claude model id such as ``"claude-opus-5"``.
32
+ api_key: an Anthropic API key; by default the SDK finds one itself
33
+ (``ANTHROPIC_API_KEY``, ``ANTHROPIC_AUTH_TOKEN`` or an ``ant auth login`` profile).
34
+ effort: ``"low"``, ``"medium"``, ``"high"``, ``"xhigh"`` or ``"max"``; omitted means
35
+ the model's default.
36
+ max_tokens: the most tokens one answer may use.
37
+ client: a ready ``anthropic.Anthropic`` client, used instead of creating one.
38
+ **engine: engine options, see :class:`utmax.providers.Translator`.
39
+ """
40
+
41
+ PROVIDER = "claude"
42
+ EXTRA = "claude"
43
+
44
+ def __init__(
45
+ self,
46
+ model: str,
47
+ *,
48
+ api_key: str | None = None,
49
+ effort: str | None = None,
50
+ max_tokens: int = 16000,
51
+ client: Any = None,
52
+ **engine: Unpack[EngineOptions],
53
+ ) -> None:
54
+ super().__init__(model, **engine)
55
+ if effort is not None and effort not in _EFFORTS:
56
+ raise InvalidOption(f"effort must be one of {', '.join(_EFFORTS)}, not {effort!r}.")
57
+ self.effort = effort
58
+ self.max_tokens = positive_int("max_tokens", max_tokens)
59
+ self._sdk = self.import_sdk("anthropic")
60
+ if client is None:
61
+ try:
62
+ client = self._sdk.Anthropic(api_key=api_key)
63
+ except self._sdk.CredentialsError as error:
64
+ raise self._no_credentials(error) from error
65
+ self._client = client
66
+
67
+ def generate_json(self, system: str, prompt: str, schema: Mapping[str, Any]) -> str:
68
+ output_config: dict[str, Any] = {"format": {"type": "json_schema", "schema": dict(schema)}}
69
+ if self.effort is not None:
70
+ output_config["effort"] = self.effort
71
+ try:
72
+ message = self._client.messages.create(
73
+ model=self.model,
74
+ max_tokens=self.max_tokens,
75
+ system=system,
76
+ messages=[{"role": "user", "content": prompt}],
77
+ output_config=output_config,
78
+ )
79
+ except TypeError as error:
80
+ # anthropic raises this TypeError before sending when it finds no credentials.
81
+ if "authentication" not in str(error).lower():
82
+ raise
83
+ raise self._no_credentials(error) from error
84
+ except self._sdk.CredentialsError as error:
85
+ raise self._no_credentials(error) from error
86
+ except self._sdk.AnthropicError as error:
87
+ mapped = stainless_error(self._sdk, error, provider=self.PROVIDER, service="Anthropic")
88
+ if mapped is None:
89
+ raise
90
+ raise mapped from error
91
+ if message.stop_reason == "refusal":
92
+ category = getattr(message.stop_details, "category", None) or "unspecified"
93
+ raise TranslationRefused(
94
+ f"Claude declined to translate this batch (refusal category: {category}).",
95
+ provider=self.PROVIDER,
96
+ )
97
+ text = "".join(block.text for block in message.content if block.type == "text")
98
+ if message.stop_reason in _CUT_OFF:
99
+ log.info("%s: the answer stopped early (%s)", self.name, message.stop_reason)
100
+ raise InvalidResponse(f"the answer was cut off ({message.stop_reason})", raw=text)
101
+ return text
102
+
103
+ def _no_credentials(self, error: Exception) -> ProviderAuthError:
104
+ return ProviderAuthError(
105
+ f"No usable Anthropic credentials were found: {redact_secrets(str(error))}",
106
+ provider=self.PROVIDER,
107
+ suggestion="Pass api_key=... or set the ANTHROPIC_API_KEY environment variable.",
108
+ )
@@ -0,0 +1,133 @@
1
+ """Gemini through the official ``google-genai`` SDK, with JSON Schema output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any, Unpack
8
+
9
+ from utmax.adapters.providers.base import (
10
+ EngineOptions,
11
+ SDKTranslator,
12
+ positive_int,
13
+ redact_secrets,
14
+ )
15
+ from utmax.core.translate.batching import InvalidResponse
16
+ from utmax.errors import (
17
+ ProviderAuthError,
18
+ ProviderError,
19
+ ProviderRateLimited,
20
+ TranslationError,
21
+ TranslationRefused,
22
+ )
23
+
24
+ __all__ = ["GeminiTranslator"]
25
+
26
+ log = logging.getLogger("utmax.translate")
27
+
28
+ _REFUSED = frozenset(
29
+ {"SAFETY", "RECITATION", "LANGUAGE", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"}
30
+ )
31
+
32
+
33
+ class GeminiTranslator(SDKTranslator):
34
+ """Translates with Google's Gemini models (``pip install "u-transcript-max[gemini]"``).
35
+
36
+ Args:
37
+ model: a Gemini model id such as ``"gemini-2.5-flash"``.
38
+ api_key: a Gemini API key; by default the SDK reads ``GEMINI_API_KEY`` or
39
+ ``GOOGLE_API_KEY``.
40
+ retry_attempts: tries per request, the first included, on HTTP 408, 429 and 5xx; the
41
+ SDK does not retry unless asked to.
42
+ client: a ready ``google.genai.Client``, used instead of creating one.
43
+ **engine: engine options, see :class:`utmax.providers.Translator`.
44
+ """
45
+
46
+ PROVIDER = "gemini"
47
+ EXTRA = "gemini"
48
+
49
+ def __init__(
50
+ self,
51
+ model: str,
52
+ *,
53
+ api_key: str | None = None,
54
+ retry_attempts: int = 5,
55
+ client: Any = None,
56
+ **engine: Unpack[EngineOptions],
57
+ ) -> None:
58
+ super().__init__(model, **engine)
59
+ self.retry_attempts = positive_int("retry_attempts", retry_attempts)
60
+ self._genai = self.import_sdk("google.genai")
61
+ self._types = self.import_sdk("google.genai.types")
62
+ self._errors = self.import_sdk("google.genai.errors")
63
+ self._httpx = self.import_sdk("httpx")
64
+ if client is None:
65
+ try:
66
+ client = self._genai.Client(api_key=api_key)
67
+ except ValueError as error:
68
+ raise ProviderAuthError(
69
+ f"No Gemini API key was found: {redact_secrets(str(error))}",
70
+ provider=self.PROVIDER,
71
+ suggestion="Pass api_key=... or set the GEMINI_API_KEY environment variable.",
72
+ ) from error
73
+ self._client = client
74
+
75
+ def generate_json(self, system: str, prompt: str, schema: Mapping[str, Any]) -> str:
76
+ types = self._types
77
+ config = types.GenerateContentConfig(
78
+ system_instruction=system,
79
+ response_mime_type="application/json",
80
+ response_json_schema=dict(schema),
81
+ http_options=types.HttpOptions(
82
+ retry_options=types.HttpRetryOptions(attempts=self.retry_attempts)
83
+ ),
84
+ # No tools are sent; this skips the SDK's function-calling loop and its log notice.
85
+ automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
86
+ )
87
+ try:
88
+ response = self._client.models.generate_content(
89
+ model=self.model, contents=prompt, config=config
90
+ )
91
+ except self._errors.APIError as error:
92
+ raise self._api_error(error) from error
93
+ except self._httpx.TransportError as error:
94
+ raise ProviderError(
95
+ f"Could not reach Gemini: {redact_secrets(str(error))}", provider=self.PROVIDER
96
+ ) from error
97
+ feedback = response.prompt_feedback
98
+ if feedback is not None and feedback.block_reason:
99
+ raise TranslationRefused(
100
+ f"Gemini blocked this batch ({_label(feedback.block_reason)}).",
101
+ provider=self.PROVIDER,
102
+ )
103
+ candidate = response.candidates[0] if response.candidates else None
104
+ reason = _label(candidate.finish_reason) if candidate is not None else ""
105
+ if reason in _REFUSED:
106
+ raise TranslationRefused(
107
+ f"Gemini stopped answering this batch ({reason}).", provider=self.PROVIDER
108
+ )
109
+ text: str = response.text or ""
110
+ if reason == "MAX_TOKENS":
111
+ log.info("%s: the answer stopped early (MAX_TOKENS)", self.name)
112
+ raise InvalidResponse("the answer was cut off (MAX_TOKENS)", raw=text)
113
+ return text
114
+
115
+ def _api_error(self, error: Any) -> TranslationError:
116
+ code = error.code
117
+ detail = redact_secrets(f"{error.status}: {error.message}")
118
+ if code in (401, 403) or "api key" in str(error.message or "").lower():
119
+ return ProviderAuthError(
120
+ f"Credentials rejected by Gemini (HTTP {code}): {detail}", provider=self.PROVIDER
121
+ )
122
+ if code == 429:
123
+ return ProviderRateLimited(
124
+ f"Still rate-limited by Gemini after {self.retry_attempts} attempts: {detail}",
125
+ provider=self.PROVIDER,
126
+ )
127
+ return ProviderError(
128
+ f"HTTP {code} from Gemini: {detail}", provider=self.PROVIDER, status_code=code
129
+ )
130
+
131
+
132
+ def _label(value: object) -> str:
133
+ return str(getattr(value, "value", value) or "")
@@ -0,0 +1,169 @@
1
+ """OpenAI and OpenAI-compatible servers through the official ``openai`` SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ from collections.abc import Mapping
9
+ from typing import Any, Literal, Unpack
10
+
11
+ from utmax.adapters.http import redact
12
+ from utmax.adapters.providers.base import EngineOptions, SDKTranslator, stainless_error
13
+ from utmax.core.translate.batching import InvalidResponse
14
+ from utmax.errors import InvalidOption, ProviderAuthError, TranslationRefused
15
+
16
+ __all__ = ["JsonMode", "OpenAITranslator"]
17
+
18
+ log = logging.getLogger("utmax.translate")
19
+
20
+ JsonMode = Literal["auto", "json_schema", "json_object", "prompt"]
21
+
22
+ _MODES: tuple[JsonMode, ...] = ("auto", "json_schema", "json_object", "prompt")
23
+ _STEP_DOWN: dict[str, JsonMode] = {"json_schema": "json_object", "json_object": "prompt"}
24
+ _FORMAT_WORDS = ("response_format", "json_schema", "json_object", "structured output")
25
+ _SCHEMA_NAME = "subtitle_translation"
26
+ _NO_KEY = "not-needed"
27
+
28
+
29
+ class OpenAITranslator(SDKTranslator):
30
+ """Translates with OpenAI or any OpenAI-compatible server (``pip install
31
+ "u-transcript-max[openai]"``): Ollama, LM Studio, vLLM, Groq, DeepSeek and others.
32
+
33
+ Args:
34
+ model: a model id such as ``"gpt-5-mini"`` or ``"llama3.1:8b"``.
35
+ api_key: the API key. Without ``base_url`` the SDK falls back to ``OPENAI_API_KEY``;
36
+ with ``base_url`` only this argument is sent, so your OpenAI key never reaches
37
+ another server and keyless local servers need nothing.
38
+ base_url: the address of an OpenAI-compatible API, such as
39
+ ``"http://localhost:11434/v1"`` for Ollama.
40
+ json_mode: how the answer is kept to JSON: ``"json_schema"`` (strict structured
41
+ output), ``"json_object"``, ``"prompt"`` (instructions only) or ``"auto"``, which
42
+ starts with ``json_schema`` and steps down each time the server rejects the mode,
43
+ remembering the working one for later requests.
44
+ client: a ready ``openai.OpenAI`` client, used instead of creating one.
45
+ **engine: engine options, see :class:`utmax.providers.Translator`.
46
+ """
47
+
48
+ PROVIDER = "openai"
49
+ EXTRA = "openai"
50
+
51
+ def __init__(
52
+ self,
53
+ model: str,
54
+ *,
55
+ api_key: str | None = None,
56
+ base_url: str | None = None,
57
+ json_mode: JsonMode = "auto",
58
+ client: Any = None,
59
+ **engine: Unpack[EngineOptions],
60
+ ) -> None:
61
+ super().__init__(model, **engine)
62
+ if json_mode not in _MODES:
63
+ raise InvalidOption(f"json_mode must be one of {', '.join(_MODES)}, not {json_mode!r}.")
64
+ self.json_mode = json_mode
65
+ self._mode: JsonMode = "json_schema" if json_mode == "auto" else json_mode
66
+ self._lock = threading.Lock()
67
+ self._extra_body: dict[str, Any] | None = None
68
+ self._service = "OpenAI" if base_url is None else f"the server at {redact(base_url)}"
69
+ self._sdk = self.import_sdk("openai")
70
+ self._client = client if client is not None else self._make_client(api_key, base_url)
71
+
72
+ @property
73
+ def mode(self) -> JsonMode:
74
+ """The JSON mode of the next request; ``"auto"`` settles on one of the other three."""
75
+ return self._mode
76
+
77
+ def generate_json(self, system: str, prompt: str, schema: Mapping[str, Any]) -> str:
78
+ while True:
79
+ mode = self._mode
80
+ try:
81
+ completion = self._client.chat.completions.create(
82
+ **self._request(mode, system, prompt, schema)
83
+ )
84
+ except self._sdk.OpenAIError as error:
85
+ if self._rejects_json_mode(mode, error):
86
+ self._step_down(mode)
87
+ continue
88
+ mapped = stainless_error(
89
+ self._sdk, error, provider=self.PROVIDER, service=self._service
90
+ )
91
+ if mapped is None:
92
+ raise
93
+ raise mapped from error
94
+ return self._answer(completion)
95
+
96
+ def _make_client(self, api_key: str | None, base_url: str | None) -> Any:
97
+ if base_url is not None and api_key is None:
98
+ api_key = _NO_KEY
99
+ try:
100
+ return self._sdk.OpenAI(api_key=api_key, base_url=base_url)
101
+ except self._sdk.OpenAIError as error:
102
+ raise ProviderAuthError(
103
+ "No OpenAI API key was found for the openai translator.",
104
+ provider=self.PROVIDER,
105
+ suggestion="Pass api_key=... or set the OPENAI_API_KEY environment variable.",
106
+ ) from error
107
+
108
+ def _request(
109
+ self, mode: JsonMode, system: str, prompt: str, schema: Mapping[str, Any]
110
+ ) -> dict[str, Any]:
111
+ if mode != "json_schema":
112
+ system += (
113
+ "\n\nThe reply must be a JSON object that matches this JSON Schema:\n"
114
+ + json.dumps(dict(schema))
115
+ )
116
+ request: dict[str, Any] = {
117
+ "model": self.model,
118
+ "messages": [
119
+ {"role": "system", "content": system},
120
+ {"role": "user", "content": prompt},
121
+ ],
122
+ }
123
+ if mode == "json_schema":
124
+ request["response_format"] = {
125
+ "type": "json_schema",
126
+ "json_schema": {"name": _SCHEMA_NAME, "schema": dict(schema), "strict": True},
127
+ }
128
+ elif mode == "json_object":
129
+ request["response_format"] = {"type": "json_object"}
130
+ if self._extra_body is not None:
131
+ request["extra_body"] = self._extra_body
132
+ return request
133
+
134
+ def _rejects_json_mode(self, mode: JsonMode, error: Exception) -> bool:
135
+ if self.json_mode != "auto" or mode not in _STEP_DOWN:
136
+ return False
137
+ if not isinstance(error, (self._sdk.BadRequestError, self._sdk.UnprocessableEntityError)):
138
+ return False
139
+ text = f"{getattr(error, 'message', '')} {getattr(error, 'body', '')}".lower()
140
+ return any(word in text for word in _FORMAT_WORDS)
141
+
142
+ def _step_down(self, tried: JsonMode) -> None:
143
+ with self._lock:
144
+ if self._mode == tried:
145
+ self._mode = _STEP_DOWN[tried]
146
+ log.info(
147
+ "%s rejected response_format %s; using %s from now on",
148
+ self.name,
149
+ tried,
150
+ self._mode,
151
+ )
152
+
153
+ def _answer(self, completion: Any) -> str:
154
+ if not completion.choices:
155
+ raise InvalidResponse("the answer had no choices")
156
+ choice = completion.choices[0]
157
+ refusal = getattr(choice.message, "refusal", None)
158
+ if refusal:
159
+ raise TranslationRefused(
160
+ f"The model declined to translate this batch: {refusal}", provider=self.PROVIDER
161
+ )
162
+ if choice.finish_reason == "content_filter":
163
+ raise TranslationRefused(
164
+ "The provider's content filter blocked this batch.", provider=self.PROVIDER
165
+ )
166
+ text: str = choice.message.content or ""
167
+ if choice.finish_reason == "length":
168
+ raise InvalidResponse("the answer was cut off (finish_reason=length)", raw=text)
169
+ return text
@@ -0,0 +1,59 @@
1
+ """OpenRouter, which speaks the OpenAI API, through the official ``openai`` SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Unpack
7
+
8
+ from utmax.adapters.providers.base import EngineOptions
9
+ from utmax.adapters.providers.openai import OpenAITranslator
10
+ from utmax.errors import ProviderAuthError
11
+
12
+ __all__ = ["OPENROUTER_BASE_URL", "OpenRouterTranslator"]
13
+
14
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
15
+
16
+
17
+ class OpenRouterTranslator(OpenAITranslator):
18
+ """Translates with any model on OpenRouter (``pip install "u-transcript-max[openrouter]"``).
19
+
20
+ Every request asks OpenRouter to route only to hosts that support all parameters sent
21
+ (``provider.require_parameters``), so structured JSON output is honoured; the JSON mode
22
+ steps down like ``OpenAITranslator(json_mode="auto")``.
23
+
24
+ Args:
25
+ model: an OpenRouter model id such as ``"anthropic/claude-opus-5"``.
26
+ api_key: the OpenRouter key; by default ``OPENROUTER_API_KEY`` (``OPENAI_API_KEY`` is
27
+ never used).
28
+ app_name: your application's name, sent as OpenRouter's ``X-Title`` header.
29
+ client: a ready ``openai.OpenAI`` client pointed at OpenRouter.
30
+ **engine: engine options, see :class:`utmax.providers.Translator`.
31
+ """
32
+
33
+ PROVIDER = "openrouter"
34
+ EXTRA = "openrouter"
35
+
36
+ def __init__(
37
+ self,
38
+ model: str,
39
+ *,
40
+ api_key: str | None = None,
41
+ app_name: str | None = None,
42
+ client: Any = None,
43
+ **engine: Unpack[EngineOptions],
44
+ ) -> None:
45
+ self.app_name = app_name
46
+ super().__init__(model, api_key=api_key, client=client, **engine)
47
+ self._service = "OpenRouter"
48
+ self._extra_body = {"provider": {"require_parameters": True}}
49
+
50
+ def _make_client(self, api_key: str | None, base_url: str | None) -> Any:
51
+ key = api_key or os.environ.get("OPENROUTER_API_KEY")
52
+ if not key:
53
+ raise ProviderAuthError(
54
+ "No OpenRouter API key was found for the openrouter translator.",
55
+ provider=self.PROVIDER,
56
+ suggestion="Pass api_key=... or set the OPENROUTER_API_KEY environment variable.",
57
+ )
58
+ headers = {"X-Title": self.app_name} if self.app_name else None
59
+ return self._sdk.OpenAI(api_key=key, base_url=OPENROUTER_BASE_URL, default_headers=headers)