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.
- u_transcript_max-0.1.0a1.dist-info/METADATA +67 -0
- u_transcript_max-0.1.0a1.dist-info/RECORD +77 -0
- u_transcript_max-0.1.0a1.dist-info/WHEEL +4 -0
- u_transcript_max-0.1.0a1.dist-info/entry_points.txt +2 -0
- u_transcript_max-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- utmax/__init__.py +651 -0
- utmax/_version.py +1 -0
- utmax/adapters/__init__.py +1 -0
- utmax/adapters/downloader.py +479 -0
- utmax/adapters/ffmpeg.py +174 -0
- utmax/adapters/files.py +130 -0
- utmax/adapters/http.py +285 -0
- utmax/adapters/innertube.py +207 -0
- utmax/adapters/providers/__init__.py +45 -0
- utmax/adapters/providers/base.py +177 -0
- utmax/adapters/providers/claude.py +108 -0
- utmax/adapters/providers/gemini.py +133 -0
- utmax/adapters/providers/openai.py +169 -0
- utmax/adapters/providers/openrouter.py +59 -0
- utmax/adapters/watch_page.py +73 -0
- utmax/client.py +300 -0
- utmax/compat/__init__.py +134 -0
- utmax/compat/_api.py +203 -0
- utmax/compat/_bridge.py +212 -0
- utmax/compat/_errors.py +300 -0
- utmax/compat/_settings.py +13 -0
- utmax/compat/_transcripts.py +440 -0
- utmax/compat/formatters.py +241 -0
- utmax/compat/proxies.py +129 -0
- utmax/core/__init__.py +6 -0
- utmax/core/bilingual.py +107 -0
- utmax/core/browse.py +397 -0
- utmax/core/captions.py +251 -0
- utmax/core/clients.py +113 -0
- utmax/core/downloads.py +165 -0
- utmax/core/filenames.py +298 -0
- utmax/core/formats.py +198 -0
- utmax/core/ids.py +210 -0
- utmax/core/languages.py +371 -0
- utmax/core/media/__init__.py +7 -0
- utmax/core/media/boxes.py +266 -0
- utmax/core/media/fmp4.py +394 -0
- utmax/core/media/moov.py +299 -0
- utmax/core/media/mux.py +423 -0
- utmax/core/media/progressive.py +267 -0
- utmax/core/media/tables.py +129 -0
- utmax/core/media/tx3g.py +191 -0
- utmax/core/playability.py +62 -0
- utmax/core/player.py +90 -0
- utmax/core/retry.py +45 -0
- utmax/core/segmentation.py +104 -0
- utmax/core/selection.py +92 -0
- utmax/core/streams.py +264 -0
- utmax/core/translate/__init__.py +1 -0
- utmax/core/translate/batching.py +157 -0
- utmax/core/translate/data/protocol.json +17 -0
- utmax/core/translate/data/request.schema.json +49 -0
- utmax/core/translate/data/response.schema.json +19 -0
- utmax/core/translate/data/system_prompt.txt +21 -0
- utmax/core/translate/protocol.py +101 -0
- utmax/core/translate/spec.py +49 -0
- utmax/core/ytdata.py +32 -0
- utmax/errors.py +523 -0
- utmax/mcp/__init__.py +30 -0
- utmax/mcp/__main__.py +6 -0
- utmax/mcp/config.py +56 -0
- utmax/mcp/server.py +626 -0
- utmax/models.py +499 -0
- utmax/providers.py +36 -0
- utmax/py.typed +0 -0
- utmax/services/__init__.py +1 -0
- utmax/services/bulk.py +454 -0
- utmax/services/collections.py +183 -0
- utmax/services/download.py +375 -0
- utmax/services/transcripts.py +87 -0
- utmax/services/translation.py +211 -0
- 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)
|