File size: 5,061 Bytes
067d7b2
4a9a269
 
 
 
067d7b2
4a9a269
 
 
 
 
067d7b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a9a269
 
 
067d7b2
4a9a269
 
067d7b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a9a269
067d7b2
 
 
 
 
4a9a269
 
 
9a40f1b
4a9a269
 
9a40f1b
 
4a9a269
 
 
 
 
 
 
 
 
067d7b2
4a9a269
 
 
 
067d7b2
 
4a9a269
 
 
 
 
 
 
 
067d7b2
 
 
 
 
 
 
 
 
 
 
 
4a9a269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
067d7b2
 
 
 
 
 
 
 
 
4a9a269
 
 
 
 
 
 
067d7b2
4a9a269
 
 
 
067d7b2
 
4a9a269
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""HTTP client for HIRO Translation API (public connect gateway or internal stage)."""

from __future__ import annotations

import json
import os
from collections.abc import Iterator
from typing import Any

import requests

# Public gateway (matches curl examples against connect.zhihuiya.com).
DEFAULT_BASE = "https://connect.zhihuiya.com/hiro_translation"

API_KEY_ENV = "HIRO_API_KEY"


def lang_to_codes(lang: str) -> tuple[str, str]:
    src, _, tgt = lang.partition("2")
    if not src or not tgt:
        raise ValueError(f"invalid lang {lang!r}; expected format like zh2en")
    return src, tgt


def translate_payload(
    content: str,
    lang: str,
    *,
    mode: str,
) -> dict[str, Any]:
    source, target = lang_to_codes(lang)
    return {
        "content": content,
        "sourceLanguageCode": source,
        "targetLanguageCode": target,
        "mode": mode,
    }


def _api_url(base_url: str, path: str) -> str:
    return f"{base_url.rstrip('/')}{path}"


def _request_headers(api_key: str | None = None) -> dict[str, str]:
    headers = {"Content-Type": "application/json"}
    key = (api_key if api_key is not None else os.environ.get(API_KEY_ENV, "")).strip()
    if key:
        headers["Authorization"] = f"Bearer {key}"
    return headers


def _translated_text(data: dict[str, Any]) -> str:
    value = data.get("textTranslated", data.get("text_translated", ""))
    return value if isinstance(value, str) else ""


def _original_text(data: dict[str, Any], fallback: str = "") -> str:
    value = data.get("textOriginal", data.get("text_original", fallback))
    return value if isinstance(value, str) else fallback


def health_ok(
    base_url: str = DEFAULT_BASE,
    *,
    api_key: str | None = None,
) -> tuple[bool, str]:
    try:
        r = requests.get(
            _api_url(base_url, "/health"),
            headers=_request_headers(api_key),
            timeout=12,
        )
        r.raise_for_status()
        data = r.json()
        if data.get("status") != "OK":
            return False, f"Unexpected response: {r.text[:200]}"
        upstream = data.get("upstream", "UNKNOWN")
        if upstream == "OK":
            return True, "Healthy · upstream OK"
        return False, f"Gateway up, upstream unavailable ({upstream})"
    except requests.RequestException as exc:
        return False, str(exc)


def translate_fast(
    text: str,
    lang: str,
    *,
    base_url: str = DEFAULT_BASE,
    api_key: str | None = None,
    timeout: int = 1800,
) -> dict[str, Any]:
    r = requests.post(
        _api_url(base_url, "/translate"),
        json=translate_payload(text, lang, mode="fast"),
        headers=_request_headers(api_key),
        timeout=timeout,
    )
    if r.status_code >= 400:
        try:
            err = r.json().get("error", r.text[:500])
        except json.JSONDecodeError:
            err = r.text[:500]
        raise RuntimeError(err)
    data = r.json()
    if data.get("state") not in (None, "success") and "error" in data:
        raise RuntimeError(str(data.get("error", data)))
    # Normalize to keys the UI already understands (snake_case aliases).
    return {
        "state": data.get("state", "success"),
        "text_original": _original_text(data, text),
        "text_translated": _translated_text(data),
        "translated_character_count": data.get("translatedCharacterCount"),
        "billing_amount": r.headers.get("X-Openapi-Amount"),
        "raw": data,
    }


def _iter_sse_json(resp: requests.Response) -> Iterator[dict[str, Any]]:
    for raw in resp.iter_lines(decode_unicode=True):
        if not raw:
            continue
        line = raw.strip()
        if not line.startswith("data:"):
            continue
        payload = line[5:].lstrip()
        if not payload:
            continue
        chunk = json.loads(payload)
        if isinstance(chunk, dict) and chunk.get("error"):
            raise RuntimeError(str(chunk["error"]))
        if isinstance(chunk, dict):
            yield {
                "state": chunk.get("state", "success"),
                "text_original": _original_text(chunk),
                "text_translated": _translated_text(chunk),
                "translated_character_count": chunk.get("translatedCharacterCount"),
                "progress": chunk.get("progress"),
                "raw": chunk,
            }


def stream_translate(
    text: str,
    lang: str,
    *,
    base_url: str = DEFAULT_BASE,
    api_key: str | None = None,
    timeout: int = 1800,
) -> Iterator[dict[str, Any]]:
    with requests.post(
        _api_url(base_url, "/translate"),
        json=translate_payload(text, lang, mode="stream"),
        headers=_request_headers(api_key),
        stream=True,
        timeout=timeout,
    ) as resp:
        if resp.status_code >= 400:
            try:
                err = resp.json().get("error", resp.text[:500])
            except (json.JSONDecodeError, ValueError):
                err = resp.text[:500]
            raise RuntimeError(err)
        yield from _iter_sse_json(resp)