Cnass commited on
Commit
375386d
·
verified ·
1 Parent(s): 279ab2b

Upload 4 files

Browse files
Files changed (4) hide show
  1. app/config.py +38 -0
  2. app/llama_client.py +75 -0
  3. app/main.py +147 -0
  4. app/translation.py +102 -0
app/config.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ def _int(name: str, default: int) -> int:
5
+ return int(os.environ.get(name, default))
6
+
7
+
8
+ def _float(name: str, default: float) -> float:
9
+ return float(os.environ.get(name, default))
10
+
11
+
12
+ MODEL_REPO = os.environ.get("MODEL_REPO", "tencent/Hy-MT2-7B-GGUF")
13
+ MODEL_FILE = os.environ.get("MODEL_FILE", "Hy-MT2-7B-Q4_K_M.gguf")
14
+ MODEL_DIR = os.environ.get("MODEL_DIR", "/app/models")
15
+
16
+ LLAMA_SERVER_HOST = os.environ.get("LLAMA_SERVER_HOST", "127.0.0.1")
17
+ LLAMA_SERVER_PORT = _int("LLAMA_SERVER_PORT", 8080)
18
+ LLAMA_SERVER_URL = f"http://{LLAMA_SERVER_HOST}:{LLAMA_SERVER_PORT}"
19
+
20
+ # Matches Hy-MT2 1.8B/7B recommended sampling params from the model card.
21
+ TEMPERATURE = _float("TEMPERATURE", 0.7)
22
+ TOP_P = _float("TOP_P", 0.6)
23
+ TOP_K = _int("TOP_K", 20)
24
+ REPEAT_PENALTY = _float("REPEAT_PENALTY", 1.05)
25
+ MAX_TOKENS = _int("MAX_TOKENS", 512)
26
+
27
+ # --parallel value the llama-server was started with; also used to bound
28
+ # how many translation requests this gateway keeps in flight at once so
29
+ # requests queue instead of overwhelming the server's slots.
30
+ PARALLEL_SLOTS = _int("PARALLEL_SLOTS", 8)
31
+
32
+ MAX_BATCH_SIZE = _int("MAX_BATCH_SIZE", 200)
33
+ REQUEST_TIMEOUT_SECONDS = _float("REQUEST_TIMEOUT_SECONDS", 120.0)
34
+
35
+ # Optional shared-secret auth for the public gateway. Empty = disabled.
36
+ API_KEY = os.environ.get("API_KEY", "")
37
+
38
+ PORT = _int("PORT", 7860)
app/llama_client.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin async client for the internal llama-server instance.
2
+
3
+ llama-server is started with --parallel N --cont-batching (see entrypoint.sh),
4
+ which makes it batch multiple in-flight requests' forward passes together
5
+ instead of running them one at a time. This is where the actual CPU
6
+ efficiency gain for many short strings comes from: we just need to keep
7
+ enough requests in flight concurrently, and llama.cpp does the batching.
8
+ """
9
+
10
+ import asyncio
11
+
12
+ import httpx
13
+
14
+ from . import config
15
+
16
+ _client: httpx.AsyncClient | None = None
17
+ # Bounds how many requests we hand to llama-server at once. Slightly above
18
+ # PARALLEL_SLOTS would over-queue on the server side instead of here; we
19
+ # keep it equal so backpressure is visible to callers via queueing time.
20
+ _semaphore = asyncio.Semaphore(config.PARALLEL_SLOTS)
21
+
22
+
23
+ def get_client() -> httpx.AsyncClient:
24
+ global _client
25
+ if _client is None:
26
+ _client = httpx.AsyncClient(
27
+ base_url=config.LLAMA_SERVER_URL,
28
+ timeout=httpx.Timeout(config.REQUEST_TIMEOUT_SECONDS),
29
+ )
30
+ return _client
31
+
32
+
33
+ async def aclose() -> None:
34
+ global _client
35
+ if _client is not None:
36
+ await _client.aclose()
37
+ _client = None
38
+
39
+
40
+ class LlamaServerError(RuntimeError):
41
+ pass
42
+
43
+
44
+ async def chat_complete(prompt: str) -> str:
45
+ client = get_client()
46
+ payload = {
47
+ "messages": [{"role": "user", "content": prompt}],
48
+ "temperature": config.TEMPERATURE,
49
+ "top_p": config.TOP_P,
50
+ "top_k": config.TOP_K,
51
+ "repeat_penalty": config.REPEAT_PENALTY,
52
+ "max_tokens": config.MAX_TOKENS,
53
+ "stream": False,
54
+ }
55
+ async with _semaphore:
56
+ try:
57
+ resp = await client.post("/v1/chat/completions", json=payload)
58
+ resp.raise_for_status()
59
+ except httpx.HTTPError as exc:
60
+ raise LlamaServerError(str(exc)) from exc
61
+
62
+ data = resp.json()
63
+ try:
64
+ return data["choices"][0]["message"]["content"].strip()
65
+ except (KeyError, IndexError) as exc:
66
+ raise LlamaServerError(f"Unexpected llama-server response: {data}") from exc
67
+
68
+
69
+ async def health() -> bool:
70
+ client = get_client()
71
+ try:
72
+ resp = await client.get("/health", timeout=5.0)
73
+ return resp.status_code == 200
74
+ except httpx.HTTPError:
75
+ return False
app/main.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+
4
+ from fastapi import Depends, FastAPI, Header, HTTPException
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from pydantic import BaseModel, Field
7
+
8
+ from . import config, llama_client
9
+ from .translation import LANGUAGES, build_prompt
10
+
11
+ app = FastAPI(title="Hy-MT2 Translation API", version="1.0.0")
12
+
13
+ app.add_middleware(
14
+ CORSMiddleware,
15
+ allow_origins=["*"],
16
+ allow_methods=["*"],
17
+ allow_headers=["*"],
18
+ )
19
+
20
+
21
+ async def verify_api_key(x_api_key: str | None = Header(default=None)) -> None:
22
+ if config.API_KEY and x_api_key != config.API_KEY:
23
+ raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key header")
24
+
25
+
26
+ class TranslateRequest(BaseModel):
27
+ text: str
28
+ target_lang: str
29
+ source_lang: str | None = None
30
+ style: str | None = None
31
+ glossary: dict[str, str] | None = None
32
+ preserve_placeholders: bool = True
33
+
34
+
35
+ class BatchTranslateRequest(BaseModel):
36
+ texts: list[str] = Field(..., min_length=1)
37
+ target_lang: str
38
+ source_lang: str | None = None
39
+ style: str | None = None
40
+ glossary: dict[str, str] | None = None
41
+ preserve_placeholders: bool = True
42
+
43
+
44
+ class TranslateResponse(BaseModel):
45
+ translation: str
46
+ elapsed_seconds: float
47
+
48
+
49
+ class BatchItem(BaseModel):
50
+ original: str
51
+ translation: str | None
52
+ error: str | None = None
53
+
54
+
55
+ class BatchTranslateResponse(BaseModel):
56
+ translations: list[BatchItem]
57
+ count: int
58
+ elapsed_seconds: float
59
+ items_per_second: float | None
60
+
61
+
62
+ @app.get("/health")
63
+ async def health_check():
64
+ backend_ok = await llama_client.health()
65
+ return {
66
+ "status": "ok" if backend_ok else "starting",
67
+ "llama_server": backend_ok,
68
+ "parallel_slots": config.PARALLEL_SLOTS,
69
+ "model_file": config.MODEL_FILE,
70
+ }
71
+
72
+
73
+ @app.get("/languages")
74
+ async def languages():
75
+ return LANGUAGES
76
+
77
+
78
+ @app.post("/translate", response_model=TranslateResponse, dependencies=[Depends(verify_api_key)])
79
+ async def translate(req: TranslateRequest):
80
+ prompt = build_prompt(
81
+ req.text,
82
+ req.target_lang,
83
+ req.source_lang,
84
+ req.style,
85
+ req.glossary,
86
+ req.preserve_placeholders,
87
+ )
88
+ start = time.perf_counter()
89
+ try:
90
+ translation = await llama_client.chat_complete(prompt)
91
+ except llama_client.LlamaServerError as exc:
92
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
93
+ elapsed = time.perf_counter() - start
94
+ return TranslateResponse(translation=translation, elapsed_seconds=round(elapsed, 4))
95
+
96
+
97
+ @app.post(
98
+ "/translate/batch",
99
+ response_model=BatchTranslateResponse,
100
+ dependencies=[Depends(verify_api_key)],
101
+ )
102
+ async def translate_batch(req: BatchTranslateRequest):
103
+ if len(req.texts) > config.MAX_BATCH_SIZE:
104
+ raise HTTPException(
105
+ status_code=400,
106
+ detail=(
107
+ f"Batch too large ({len(req.texts)} items). "
108
+ f"Split into chunks of at most {config.MAX_BATCH_SIZE} "
109
+ "(50-100 is a good size for many short strings)."
110
+ ),
111
+ )
112
+
113
+ async def _translate_one(text: str):
114
+ prompt = build_prompt(
115
+ text,
116
+ req.target_lang,
117
+ req.source_lang,
118
+ req.style,
119
+ req.glossary,
120
+ req.preserve_placeholders,
121
+ )
122
+ return await llama_client.chat_complete(prompt)
123
+
124
+ start = time.perf_counter()
125
+ results = await asyncio.gather(
126
+ *(_translate_one(t) for t in req.texts), return_exceptions=True
127
+ )
128
+ elapsed = time.perf_counter() - start
129
+
130
+ items: list[BatchItem] = []
131
+ for original, result in zip(req.texts, results):
132
+ if isinstance(result, Exception):
133
+ items.append(BatchItem(original=original, translation=None, error=str(result)))
134
+ else:
135
+ items.append(BatchItem(original=original, translation=result))
136
+
137
+ return BatchTranslateResponse(
138
+ translations=items,
139
+ count=len(req.texts),
140
+ elapsed_seconds=round(elapsed, 4),
141
+ items_per_second=round(len(req.texts) / elapsed, 2) if elapsed > 0 else None,
142
+ )
143
+
144
+
145
+ @app.on_event("shutdown")
146
+ async def shutdown():
147
+ await llama_client.aclose()
app/translation.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt construction for Hy-MT2, following the instruction formats documented
2
+ on https://huggingface.co/tencent/Hy-MT2-7B-GGUF ("Default Translation",
3
+ "Terminology", "Style" and "Structured Data" instruction variants).
4
+ """
5
+
6
+ # The 33 languages Hy-MT2 is documented to support, code -> English name.
7
+ LANGUAGES = {
8
+ "zh": "Chinese",
9
+ "zh-hant": "Traditional Chinese",
10
+ "yue": "Cantonese",
11
+ "en": "English",
12
+ "fr": "French",
13
+ "pt": "Portuguese",
14
+ "es": "Spanish",
15
+ "ja": "Japanese",
16
+ "tr": "Turkish",
17
+ "ru": "Russian",
18
+ "ar": "Arabic",
19
+ "ko": "Korean",
20
+ "th": "Thai",
21
+ "it": "Italian",
22
+ "de": "German",
23
+ "vi": "Vietnamese",
24
+ "ms": "Malay",
25
+ "id": "Indonesian",
26
+ "tl": "Filipino",
27
+ "hi": "Hindi",
28
+ "pl": "Polish",
29
+ "cs": "Czech",
30
+ "nl": "Dutch",
31
+ "km": "Khmer",
32
+ "my": "Burmese",
33
+ "fa": "Persian",
34
+ "gu": "Gujarati",
35
+ "ur": "Urdu",
36
+ "te": "Telugu",
37
+ "mr": "Marathi",
38
+ "he": "Hebrew",
39
+ "bn": "Bengali",
40
+ "ta": "Tamil",
41
+ "uk": "Ukrainian",
42
+ "bo": "Tibetan",
43
+ "kk": "Kazakh",
44
+ "mn": "Mongolian",
45
+ "ug": "Uyghur",
46
+ }
47
+
48
+ # Placeholder styles commonly found in game strings (RPG Maker control codes,
49
+ # printf-style format specifiers, ICU/handlebars-style variables) that the
50
+ # model must copy through untouched rather than translate.
51
+ _PLACEHOLDER_HINT = (
52
+ "You must NEVER translate or alter placeholders and control codes such as "
53
+ r"\N[..], \V[..], \C[..], \I[..], {variable}, ${variable}, %s, %d, %1, {{var}} "
54
+ "or similar tokens. Keep them exactly as they appear, in the same position."
55
+ )
56
+
57
+
58
+ def resolve_language(code_or_name: str) -> str:
59
+ """Map an ISO-ish code to its English name; pass through unknown values
60
+ (so callers can already supply a plain language name)."""
61
+ return LANGUAGES.get(code_or_name.strip().lower(), code_or_name)
62
+
63
+
64
+ def build_prompt(
65
+ text: str,
66
+ target_lang: str,
67
+ source_lang: str | None = None,
68
+ style: str | None = None,
69
+ glossary: dict[str, str] | None = None,
70
+ preserve_placeholders: bool = True,
71
+ ) -> str:
72
+ target_name = resolve_language(target_lang)
73
+
74
+ header = (
75
+ f"Translate the following text into {target_name}. "
76
+ "Note that you should only output the translated result "
77
+ "without any additional explanation"
78
+ )
79
+ if source_lang:
80
+ source_name = resolve_language(source_lang)
81
+ header += f". The source text is in {source_name}"
82
+ header += "."
83
+
84
+ parts = [header]
85
+
86
+ if style:
87
+ parts.append(
88
+ f"Note that the translation style must strictly conform to [{style}]."
89
+ )
90
+
91
+ if glossary:
92
+ ref_lines = "\n".join(f"{k} translates to {v}" for k, v in glossary.items())
93
+ parts.insert(
94
+ 0,
95
+ "Reference the following translations:\n" + ref_lines,
96
+ )
97
+
98
+ if preserve_placeholders:
99
+ parts.append(_PLACEHOLDER_HINT)
100
+
101
+ instruction = "\n".join(parts)
102
+ return f"{instruction}\n\n{text}"