GitHub Actions commited on
Commit
2d9f3fa
·
1 Parent(s): 0fab727

Deploy backend from GitHub

Browse files
INFERENCE_CONFIG.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Online inference configuration
2
+
3
+ The production Spaces use a Groq organization confirmed by its owner to be on
4
+ the Free plan. Store the credential as the `GROQ_API_KEY` Space Secret.
5
+
6
+ Set the Space Variable `INFERENCE_FREE_ONLY=true`. The `groq` route defaults to
7
+ `qwen/qwen3.8-27b`; `GROQ_MODEL_NAME` may select another explicitly permitted
8
+ Groq model. These models can be billed on a paid Groq account: the routing
9
+ policy does not inspect or change the organization's billing plan.
10
+
11
+ With this policy, `auto` tries Groq and then `openrouter-free` if configured.
12
+ It never tries the legacy Alibaba, DeepSeek, Gemini, or retired Llama routes.
13
+ The OpenRouter route requires `OPENROUTER_API_KEY` and uses `openrouter/free`.
14
+ Changing its endpoint or model to a paid alternative is rejected.
15
+
16
+ The application sends an explicit user-agent header because the Groq gateway
17
+ can reject Python urllib's default user agent before authenticating the key.
18
+ HTTP 401 is reported as `PROVIDER_AUTH_FAILED`; HTTP 403 is reported separately
19
+ as `PROVIDER_ACCESS_DENIED`. Neither error body nor key is returned as content.
20
+
21
+ REST, Gradio v3 and legacy Groq mission/analysis calls share the bounded
22
+ inference adapter. Groq Qwen uses instruct mode to avoid spending the response
23
+ budget on reasoning instead of the requested translation or conversation.
24
+
25
+ `GET /api/health` lists configuration only. Verify a short synthetic request to
26
+ `POST /api/translate` with `ai_model: "groq"` and check the returned model and
27
+ route before claiming provider readiness. Configuration metadata is not a
28
+ linguistic-quality evaluation.
requirements.txt CHANGED
@@ -1,49 +1,49 @@
1
- # --- CORE UTILS ---
2
- setuptools==69.5.1
3
- wheel
4
- pytz
5
-
6
- # --- AI & TRANSCRIPTION ---
7
- openai-whisper @ git+https://github.com/openai/whisper.git
8
- rapidfuzz
9
- pandas
10
-
11
- # --- INTERFACE ---
12
- gradio>=4.36.0
13
-
14
- # --- DEPENDENCIES ---
15
- # cpu-basic Spaces must not mix CPU torch with CUDA audio/vision extensions.
16
- # Keep this matched set in sync (https://pytorch.org/get-started/previous-versions/).
17
- --extra-index-url https://download.pytorch.org/whl/cpu
18
- transformers
19
- torch==2.8.0+cpu
20
- torchaudio==2.8.0+cpu
21
- torchvision==0.23.0+cpu
22
- librosa
23
- pydub
24
- ffmpeg-python
25
- jiwer
26
- google-genai
27
- groq
28
- python-dotenv
29
- requests
30
- yt-dlp
31
- soundfile
32
-
33
- # --- TRUST / BLOCKCHAIN & TRAINER ---
34
- web3>=6.0.0
35
- py-solc-x>=1.1.1
36
- autotrain-advanced
37
-
38
- # --- RAG PIPELINE (LOCAL EMBEDDINGS) ---
39
- sentence-transformers
40
- faiss-cpu
41
-
42
- # --- FASTAPI MEGA-SERVER ---
43
- fastapi
44
- pydantic>=2.7,<3
45
- uvicorn
46
- openai
47
-
48
- # Private, consent-gated recording encryption
49
- cryptography>=43
 
1
+ # --- CORE UTILS ---
2
+ setuptools==69.5.1
3
+ wheel
4
+ pytz
5
+
6
+ # --- AI & TRANSCRIPTION ---
7
+ openai-whisper @ git+https://github.com/openai/whisper.git
8
+ rapidfuzz
9
+ pandas
10
+
11
+ # --- INTERFACE ---
12
+ gradio>=4.36.0
13
+
14
+ # --- DEPENDENCIES ---
15
+ # cpu-basic Spaces must not mix CPU torch with CUDA audio/vision extensions.
16
+ # Keep this matched set in sync (https://pytorch.org/get-started/previous-versions/).
17
+ --extra-index-url https://download.pytorch.org/whl/cpu
18
+ transformers
19
+ torch==2.8.0+cpu
20
+ torchaudio==2.8.0+cpu
21
+ torchvision==0.23.0+cpu
22
+ librosa
23
+ pydub
24
+ ffmpeg-python
25
+ jiwer
26
+ google-genai
27
+ groq
28
+ python-dotenv
29
+ requests
30
+ yt-dlp
31
+ soundfile
32
+
33
+ # --- TRUST / BLOCKCHAIN & TRAINER ---
34
+ web3>=6.0.0
35
+ py-solc-x>=1.1.1
36
+ autotrain-advanced
37
+
38
+ # --- RAG PIPELINE (LOCAL EMBEDDINGS) ---
39
+ sentence-transformers
40
+ faiss-cpu
41
+
42
+ # --- FASTAPI MEGA-SERVER ---
43
+ fastapi
44
+ pydantic>=2.7,<3
45
+ uvicorn
46
+ openai
47
+
48
+ # Private, consent-gated recording encryption
49
+ cryptography>=43
src/inference_service.py CHANGED
@@ -1,174 +1,174 @@
1
- """Small, fail-closed provider adapter shared by REST and Gradio.
2
-
3
- Configured routes are not health claims. Explicit model choices never fall back
4
- to another provider; ``auto`` may choose from configured routes.
5
- """
6
- import json
7
- import os
8
- import re
9
- import time
10
- import urllib.error
11
- import urllib.parse
12
- import urllib.request
13
-
14
-
15
- ROUTES = {
16
- "qwen": ("QWEN", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus"),
17
- "llama": ("GROQ", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
18
- "deepseek": ("DEEPSEEK", "https://api.deepseek.com", "deepseek-chat"),
19
- "gemini": ("GEMINI", "https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"),
20
- "nemotron": ("OPENROUTER", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-nano-30b-a3b:free"),
21
- "gpt-oss": ("OPENROUTER", "https://openrouter.ai/api/v1", "openai/gpt-oss-20b:free"),
22
- "lfm": ("OPENROUTER", "https://openrouter.ai/api/v1", "liquid/lfm-2.5-1.2b-instruct:free"),
23
- "openrouter-free": ("OPENROUTER", "https://openrouter.ai/api/v1", "openrouter/free"),
24
- "groq": ("GROQ", "https://api.groq.com/openai/v1", "qwen/qwen3.8-27b"),
25
- }
26
- MODEL_ENV = {
27
- "nemotron": "OPENROUTER_NEMOTRON_MODEL_NAME",
28
- "gpt-oss": "OPENROUTER_GPT_OSS_MODEL_NAME",
29
- "lfm": "OPENROUTER_LFM_MODEL_NAME",
30
- "openrouter-free": "OPENROUTER_FREE_MODEL_NAME",
31
- }
32
- ERROR_TEXT = re.compile(
33
- r"^(?:AI[_ ]GENERATION[_ ]FAILED|AI generation failed|Transcription (?:failed|unavailable)|"
34
- r"Translation (?:failed|unavailable)|Error\s*:|Service unavailable)", re.I
35
- )
36
-
37
-
38
- def failure(code, message):
39
- return {"ok": False, "error": {"code": code, "message": message}}
40
-
41
-
42
- def valid_content(text):
43
- return isinstance(text, str) and 0 < len(text.strip()) <= 4000 and not ERROR_TEXT.search(text.strip())
44
-
45
-
46
- def free_only():
47
- """Deployment policy; Groq additionally requires the operator's Free plan."""
48
- return os.getenv("INFERENCE_FREE_ONLY", "false").strip().lower() in {"1", "true", "yes"}
49
-
50
-
51
- def _route(choice):
52
- prefix, default_url, default_model = ROUTES[choice]
53
- key = os.getenv(prefix + "_API_KEY", "").strip()
54
- if prefix == "GEMINI":
55
- key = key or os.getenv("GOOGLE_API_KEY", "").strip()
56
- if not key or key.lower() in {"your-api-key-here", "none", "null"}:
57
- return None
58
- model = os.getenv(MODEL_ENV.get(choice, prefix + "_MODEL_NAME"), default_model).strip()
59
- base_url = os.getenv(prefix + "_BASE_URL", default_url).strip().rstrip("/")
60
- parsed = urllib.parse.urlsplit(base_url)
61
- # Only operator-configured endpoints are used. Never put keys in URLs.
62
- if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
63
- return None
64
- if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
65
- return None
66
- if not model:
67
- return None
68
- if free_only():
69
- groq_free = (
70
- choice == "groq" and base_url == "https://api.groq.com/openai/v1"
71
- and model in {"qwen/qwen3.8-27b", "openai/gpt-oss-20b", "openai/gpt-oss-120b"}
72
- )
73
- router_free = (
74
- choice == "openrouter-free" and base_url == "https://openrouter.ai/api/v1"
75
- and model == "openrouter/free"
76
- )
77
- if not (groq_free or router_free):
78
- return None
79
- return {"key": key, "url": base_url + "/chat/completions", "model": model, "route": choice}
80
-
81
-
82
- def available_routes():
83
- """Return non-secret configuration metadata, without claiming availability."""
84
- return [
85
- {"route": name, "model": route["model"], "configured": True, "verified": False}
86
- for name in ROUTES if (route := _route(name))
87
- ]
88
-
89
-
90
- class _NoRedirect(urllib.request.HTTPRedirectHandler):
91
- def redirect_request(self, req, fp, code, msg, headers, newurl):
92
- # Do not forward Authorization to a redirected host.
93
- return None
94
-
95
-
96
- class _OutputFailure(Exception):
97
- def __init__(self, code, message):
98
- super().__init__(message)
99
- self.code = code
100
- self.message = message
101
-
102
-
103
- def _post(route, prompt, timeout=10):
104
- payload = {
105
- "model": route["model"],
106
- "messages": [{"role": "user", "content": prompt}],
107
- "temperature": 0.3,
108
- "max_tokens": 768,
109
- }
110
- if route["url"].startswith("https://api.groq.com/") and route["model"] == "qwen/qwen3.8-27b":
111
- payload["reasoning_effort"] = "none"
112
- body = json.dumps(payload).encode("utf-8")
113
- request = urllib.request.Request(route["url"], data=body, headers={
114
- "Authorization": "Bearer " + route["key"],
115
- "Content-Type": "application/json",
116
- "User-Agent": "PureDialectApps/1.0",
117
- }, method="POST")
118
- with urllib.request.build_opener(_NoRedirect).open(request, timeout=timeout) as response:
119
- raw = response.read(1024 * 1024 + 1)
120
- if len(raw) > 1024 * 1024:
121
- raise ValueError("Response exceeded limit")
122
- payload = json.loads(raw)
123
- choice = payload["choices"][0]
124
- finish_reason = choice.get("finish_reason")
125
- if finish_reason in {"length", "max_tokens"}:
126
- raise _OutputFailure("OUTPUT_TRUNCATED", "The model response was cut short. Shorten the text or retry.")
127
- if finish_reason != "stop" or choice.get("message", {}).get("tool_calls"):
128
- raise _OutputFailure("INVALID_OUTPUT", "The model did not return a complete usable response.")
129
- return choice["message"]["content"]
130
-
131
-
132
- def generate_text(prompt, model_choice="auto", *, deadline=None):
133
- if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 24000:
134
- return failure("INVALID_REQUEST", "A non-empty, bounded inference prompt is required.")
135
- choice = str(model_choice or "auto").strip().lower()
136
- if choice != "auto" and choice not in ROUTES:
137
- return failure("MODEL_UNAVAILABLE", "The selected model route is not supported.")
138
- names = list(ROUTES) if choice == "auto" else [choice]
139
- if free_only() and choice == "auto":
140
- names = ["groq", "openrouter-free"]
141
- configured = [route for name in names if (route := _route(name))]
142
- if not configured:
143
- return failure("MODEL_UNAVAILABLE", "The selected model route is not configured.")
144
- deadline = min(deadline or (time.monotonic() + 30), time.monotonic() + 30)
145
- attempts = []
146
- result = failure("PROVIDER_UNAVAILABLE", "The selected provider is unavailable. Please retry.")
147
- for route in configured[:3]:
148
- remaining = deadline - time.monotonic()
149
- if remaining <= 0:
150
- break
151
- try:
152
- content = _post(route, prompt, timeout=min(10, remaining))
153
- if valid_content(content):
154
- return {"ok": True, "text": content.strip(), "model": route["model"], "route": route["route"], "attempts": attempts}
155
- result = failure("INVALID_OUTPUT", "The model did not return usable content.")
156
- except _OutputFailure as exc:
157
- result = failure(exc.code, exc.message)
158
- except urllib.error.HTTPError as exc:
159
- if exc.code == 401:
160
- result = failure("PROVIDER_AUTH_FAILED", "The selected provider rejected its credentials.")
161
- elif exc.code == 403:
162
- result = failure("PROVIDER_ACCESS_DENIED", "The provider denied access. Check its model and project permissions.")
163
- elif exc.code == 429:
164
- result = failure("PROVIDER_RATE_LIMITED", "The selected provider is rate limited or has no remaining quota.")
165
- elif exc.code in {400, 404, 422}:
166
- result = failure("PROVIDER_CONFIGURATION_ERROR", "The selected provider rejected the configured model or request.")
167
- else:
168
- result = failure("PROVIDER_UNAVAILABLE", "The selected provider is unavailable. Please retry.")
169
- except (TimeoutError, urllib.error.URLError):
170
- result = failure("PROVIDER_UNAVAILABLE", "The selected provider could not be reached. Please retry.")
171
- except Exception:
172
- result = failure("INVALID_OUTPUT", "The model did not return usable content.")
173
- attempts.append({"route": route["route"], "code": result["error"]["code"]})
174
- return {**result, "attempts": attempts}
 
1
+ """Small, fail-closed provider adapter shared by REST and Gradio.
2
+
3
+ Configured routes are not health claims. Explicit model choices never fall back
4
+ to another provider; ``auto`` may choose from configured routes.
5
+ """
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+
15
+ ROUTES = {
16
+ "qwen": ("QWEN", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus"),
17
+ "llama": ("GROQ", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
18
+ "deepseek": ("DEEPSEEK", "https://api.deepseek.com", "deepseek-chat"),
19
+ "gemini": ("GEMINI", "https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"),
20
+ "nemotron": ("OPENROUTER", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-nano-30b-a3b:free"),
21
+ "gpt-oss": ("OPENROUTER", "https://openrouter.ai/api/v1", "openai/gpt-oss-20b:free"),
22
+ "lfm": ("OPENROUTER", "https://openrouter.ai/api/v1", "liquid/lfm-2.5-1.2b-instruct:free"),
23
+ "openrouter-free": ("OPENROUTER", "https://openrouter.ai/api/v1", "openrouter/free"),
24
+ "groq": ("GROQ", "https://api.groq.com/openai/v1", "qwen/qwen3.8-27b"),
25
+ }
26
+ MODEL_ENV = {
27
+ "nemotron": "OPENROUTER_NEMOTRON_MODEL_NAME",
28
+ "gpt-oss": "OPENROUTER_GPT_OSS_MODEL_NAME",
29
+ "lfm": "OPENROUTER_LFM_MODEL_NAME",
30
+ "openrouter-free": "OPENROUTER_FREE_MODEL_NAME",
31
+ }
32
+ ERROR_TEXT = re.compile(
33
+ r"^(?:AI[_ ]GENERATION[_ ]FAILED|AI generation failed|Transcription (?:failed|unavailable)|"
34
+ r"Translation (?:failed|unavailable)|Error\s*:|Service unavailable)", re.I
35
+ )
36
+
37
+
38
+ def failure(code, message):
39
+ return {"ok": False, "error": {"code": code, "message": message}}
40
+
41
+
42
+ def valid_content(text):
43
+ return isinstance(text, str) and 0 < len(text.strip()) <= 4000 and not ERROR_TEXT.search(text.strip())
44
+
45
+
46
+ def free_only():
47
+ """Deployment policy; Groq additionally requires the operator's Free plan."""
48
+ return os.getenv("INFERENCE_FREE_ONLY", "false").strip().lower() in {"1", "true", "yes"}
49
+
50
+
51
+ def _route(choice):
52
+ prefix, default_url, default_model = ROUTES[choice]
53
+ key = os.getenv(prefix + "_API_KEY", "").strip()
54
+ if prefix == "GEMINI":
55
+ key = key or os.getenv("GOOGLE_API_KEY", "").strip()
56
+ if not key or key.lower() in {"your-api-key-here", "none", "null"}:
57
+ return None
58
+ model = os.getenv(MODEL_ENV.get(choice, prefix + "_MODEL_NAME"), default_model).strip()
59
+ base_url = os.getenv(prefix + "_BASE_URL", default_url).strip().rstrip("/")
60
+ parsed = urllib.parse.urlsplit(base_url)
61
+ # Only operator-configured endpoints are used. Never put keys in URLs.
62
+ if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
63
+ return None
64
+ if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
65
+ return None
66
+ if not model:
67
+ return None
68
+ if free_only():
69
+ groq_free = (
70
+ choice == "groq" and base_url == "https://api.groq.com/openai/v1"
71
+ and model in {"qwen/qwen3.8-27b", "openai/gpt-oss-20b", "openai/gpt-oss-120b"}
72
+ )
73
+ router_free = (
74
+ choice == "openrouter-free" and base_url == "https://openrouter.ai/api/v1"
75
+ and model == "openrouter/free"
76
+ )
77
+ if not (groq_free or router_free):
78
+ return None
79
+ return {"key": key, "url": base_url + "/chat/completions", "model": model, "route": choice}
80
+
81
+
82
+ def available_routes():
83
+ """Return non-secret configuration metadata, without claiming availability."""
84
+ return [
85
+ {"route": name, "model": route["model"], "configured": True, "verified": False}
86
+ for name in ROUTES if (route := _route(name))
87
+ ]
88
+
89
+
90
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
91
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
92
+ # Do not forward Authorization to a redirected host.
93
+ return None
94
+
95
+
96
+ class _OutputFailure(Exception):
97
+ def __init__(self, code, message):
98
+ super().__init__(message)
99
+ self.code = code
100
+ self.message = message
101
+
102
+
103
+ def _post(route, prompt, timeout=10):
104
+ payload = {
105
+ "model": route["model"],
106
+ "messages": [{"role": "user", "content": prompt}],
107
+ "temperature": 0.3,
108
+ "max_tokens": 768,
109
+ }
110
+ if route["url"].startswith("https://api.groq.com/") and route["model"] == "qwen/qwen3.8-27b":
111
+ payload["reasoning_effort"] = "none"
112
+ body = json.dumps(payload).encode("utf-8")
113
+ request = urllib.request.Request(route["url"], data=body, headers={
114
+ "Authorization": "Bearer " + route["key"],
115
+ "Content-Type": "application/json",
116
+ "User-Agent": "PureDialectApps/1.0",
117
+ }, method="POST")
118
+ with urllib.request.build_opener(_NoRedirect).open(request, timeout=timeout) as response:
119
+ raw = response.read(1024 * 1024 + 1)
120
+ if len(raw) > 1024 * 1024:
121
+ raise ValueError("Response exceeded limit")
122
+ payload = json.loads(raw)
123
+ choice = payload["choices"][0]
124
+ finish_reason = choice.get("finish_reason")
125
+ if finish_reason in {"length", "max_tokens"}:
126
+ raise _OutputFailure("OUTPUT_TRUNCATED", "The model response was cut short. Shorten the text or retry.")
127
+ if finish_reason != "stop" or choice.get("message", {}).get("tool_calls"):
128
+ raise _OutputFailure("INVALID_OUTPUT", "The model did not return a complete usable response.")
129
+ return choice["message"]["content"]
130
+
131
+
132
+ def generate_text(prompt, model_choice="auto", *, deadline=None):
133
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 24000:
134
+ return failure("INVALID_REQUEST", "A non-empty, bounded inference prompt is required.")
135
+ choice = str(model_choice or "auto").strip().lower()
136
+ if choice != "auto" and choice not in ROUTES:
137
+ return failure("MODEL_UNAVAILABLE", "The selected model route is not supported.")
138
+ names = list(ROUTES) if choice == "auto" else [choice]
139
+ if free_only() and choice == "auto":
140
+ names = ["groq", "openrouter-free"]
141
+ configured = [route for name in names if (route := _route(name))]
142
+ if not configured:
143
+ return failure("MODEL_UNAVAILABLE", "The selected model route is not configured.")
144
+ deadline = min(deadline or (time.monotonic() + 30), time.monotonic() + 30)
145
+ attempts = []
146
+ result = failure("PROVIDER_UNAVAILABLE", "The selected provider is unavailable. Please retry.")
147
+ for route in configured[:3]:
148
+ remaining = deadline - time.monotonic()
149
+ if remaining <= 0:
150
+ break
151
+ try:
152
+ content = _post(route, prompt, timeout=min(10, remaining))
153
+ if valid_content(content):
154
+ return {"ok": True, "text": content.strip(), "model": route["model"], "route": route["route"], "attempts": attempts}
155
+ result = failure("INVALID_OUTPUT", "The model did not return usable content.")
156
+ except _OutputFailure as exc:
157
+ result = failure(exc.code, exc.message)
158
+ except urllib.error.HTTPError as exc:
159
+ if exc.code == 401:
160
+ result = failure("PROVIDER_AUTH_FAILED", "The selected provider rejected its credentials.")
161
+ elif exc.code == 403:
162
+ result = failure("PROVIDER_ACCESS_DENIED", "The provider denied access. Check its model and project permissions.")
163
+ elif exc.code == 429:
164
+ result = failure("PROVIDER_RATE_LIMITED", "The selected provider is rate limited or has no remaining quota.")
165
+ elif exc.code in {400, 404, 422}:
166
+ result = failure("PROVIDER_CONFIGURATION_ERROR", "The selected provider rejected the configured model or request.")
167
+ else:
168
+ result = failure("PROVIDER_UNAVAILABLE", "The selected provider is unavailable. Please retry.")
169
+ except (TimeoutError, urllib.error.URLError):
170
+ result = failure("PROVIDER_UNAVAILABLE", "The selected provider could not be reached. Please retry.")
171
+ except Exception:
172
+ result = failure("INVALID_OUTPUT", "The model did not return usable content.")
173
+ attempts.append({"route": route["route"], "code": result["error"]["code"]})
174
+ return {**result, "attempts": attempts}
src/managers.py CHANGED
@@ -1,94 +1,94 @@
1
- import os
2
- import asyncio
3
- import threading
4
- import json
5
- from threading import Lock
6
- from huggingface_hub import HfApi, hf_hub_download
7
- from google import genai
8
- from .inference_service import generate_text
9
-
10
- # ... (Keep your existing GeminiManager and HFManager code) ...
11
-
12
- class GroqManager:
13
- def __init__(self, api_key):
14
- self.api_key = api_key
15
- # We do NOT initialize the Groq client globally here anymore!
16
- self.client_available = True if self.api_key else False
17
-
18
- self.model_flash = os.getenv("GROQ_MODEL_NAME", "qwen/qwen3.8-27b")
19
- self.last_used_model = "Idle"
20
-
21
- if self.client_available:
22
- print(f"⚡ Groq Manager Connected: Standardized on {self.model_flash}")
23
-
24
- # 2. Convert to an ASYNC function
25
- async def generate_fast(self, prompt):
26
- if not self.client_available:
27
- raise Exception("Groq API Key not found.")
28
-
29
- # Legacy mission/analysis calls share the same policy, deadline and transport.
30
- result = await asyncio.to_thread(generate_text, prompt, "groq")
31
- if not result.get("ok"):
32
- raise RuntimeError(result.get("error", {}).get("message", "Groq inference unavailable."))
33
- self.last_used_model = result["model"]
34
-
35
- class GroqResponse:
36
- def __init__(self, text):
37
- self.text = text
38
-
39
- return GroqResponse(result["text"])
40
-
41
- # 4. Convert this to async as well since it calls generate_fast
42
- async def generate_smart(self, prompt):
43
- # Both entry points use the same bounded Groq inference route.
44
- return await self.generate_fast(prompt)
45
-
46
- def get_status_string(self):
47
- return f"⚡ {self.last_used_model}"
48
-
49
- class GeminiManager:
50
- def __init__(self, api_key):
51
- self.api_key = api_key
52
- self.client = genai.Client(api_key=self.api_key) if self.api_key else None
53
- self.model_flash = "gemini-2.0-flash"
54
- self.last_used_model = "Idle"
55
-
56
- if self.client:
57
- print(f"🧠 Gemini Manager Connected: Standardized on {self.model_flash}")
58
-
59
- def generate_fast(self, prompt):
60
- if not self.client: raise Exception("Google API Key not found.")
61
- self.last_used_model = "Gemini 2.0 Flash"
62
- return self.client.models.generate_content(model=self.model_flash, contents=prompt)
63
-
64
- def generate_smart(self, prompt):
65
- return self.generate_fast(prompt)
66
-
67
- def get_status_string(self):
68
- return f"⚡ {self.last_used_model}"
69
-
70
- class HFManager:
71
- def __init__(self, config):
72
- self.config = config
73
- self.api = HfApi(token=self.config.HF_TOKEN)
74
- self.lock = Lock()
75
-
76
- def pull_datasets(self):
77
- """Fail closed: governed datasets are never restored from publication."""
78
- print("🔒 Governed dataset startup pull is disabled.")
79
- return {"status": "disabled", "downloaded": 0}
80
-
81
- def seed_initial_data(self):
82
- p_path = os.path.join(self.config.PROFILES_DIR, "NSL Lab Trainer.json")
83
- if not os.path.exists(p_path):
84
- with open(p_path, 'w') as f: json.dump({"lab_name": "NSL", "jargon": {}, "pragmatic_rules": []}, f)
85
-
86
- def push_update(self, filepath, commit_msg="Update"):
87
- del filepath, commit_msg
88
- print("🔒 Working-dataset publication is disabled; no file was uploaded.")
89
- return {"status": "disabled", "uploaded": False}
90
-
91
- def upload_audio_sample(self, audio_path, dialect):
92
- del audio_path, dialect
93
- print("🔒 Raw-audio publication is disabled; no file was uploaded.")
94
- return None
 
1
+ import os
2
+ import asyncio
3
+ import threading
4
+ import json
5
+ from threading import Lock
6
+ from huggingface_hub import HfApi, hf_hub_download
7
+ from google import genai
8
+ from .inference_service import generate_text
9
+
10
+ # ... (Keep your existing GeminiManager and HFManager code) ...
11
+
12
+ class GroqManager:
13
+ def __init__(self, api_key):
14
+ self.api_key = api_key
15
+ # We do NOT initialize the Groq client globally here anymore!
16
+ self.client_available = True if self.api_key else False
17
+
18
+ self.model_flash = os.getenv("GROQ_MODEL_NAME", "qwen/qwen3.8-27b")
19
+ self.last_used_model = "Idle"
20
+
21
+ if self.client_available:
22
+ print(f"⚡ Groq Manager Connected: Standardized on {self.model_flash}")
23
+
24
+ # 2. Convert to an ASYNC function
25
+ async def generate_fast(self, prompt):
26
+ if not self.client_available:
27
+ raise Exception("Groq API Key not found.")
28
+
29
+ # Legacy mission/analysis calls share the same policy, deadline and transport.
30
+ result = await asyncio.to_thread(generate_text, prompt, "groq")
31
+ if not result.get("ok"):
32
+ raise RuntimeError(result.get("error", {}).get("message", "Groq inference unavailable."))
33
+ self.last_used_model = result["model"]
34
+
35
+ class GroqResponse:
36
+ def __init__(self, text):
37
+ self.text = text
38
+
39
+ return GroqResponse(result["text"])
40
+
41
+ # 4. Convert this to async as well since it calls generate_fast
42
+ async def generate_smart(self, prompt):
43
+ # Both entry points use the same bounded Groq inference route.
44
+ return await self.generate_fast(prompt)
45
+
46
+ def get_status_string(self):
47
+ return f"⚡ {self.last_used_model}"
48
+
49
+ class GeminiManager:
50
+ def __init__(self, api_key):
51
+ self.api_key = api_key
52
+ self.client = genai.Client(api_key=self.api_key) if self.api_key else None
53
+ self.model_flash = "gemini-2.0-flash"
54
+ self.last_used_model = "Idle"
55
+
56
+ if self.client:
57
+ print(f"🧠 Gemini Manager Connected: Standardized on {self.model_flash}")
58
+
59
+ def generate_fast(self, prompt):
60
+ if not self.client: raise Exception("Google API Key not found.")
61
+ self.last_used_model = "Gemini 2.0 Flash"
62
+ return self.client.models.generate_content(model=self.model_flash, contents=prompt)
63
+
64
+ def generate_smart(self, prompt):
65
+ return self.generate_fast(prompt)
66
+
67
+ def get_status_string(self):
68
+ return f"⚡ {self.last_used_model}"
69
+
70
+ class HFManager:
71
+ def __init__(self, config):
72
+ self.config = config
73
+ self.api = HfApi(token=self.config.HF_TOKEN)
74
+ self.lock = Lock()
75
+
76
+ def pull_datasets(self):
77
+ """Fail closed: governed datasets are never restored from publication."""
78
+ print("🔒 Governed dataset startup pull is disabled.")
79
+ return {"status": "disabled", "downloaded": 0}
80
+
81
+ def seed_initial_data(self):
82
+ p_path = os.path.join(self.config.PROFILES_DIR, "NSL Lab Trainer.json")
83
+ if not os.path.exists(p_path):
84
+ with open(p_path, 'w') as f: json.dump({"lab_name": "NSL", "jargon": {}, "pragmatic_rules": []}, f)
85
+
86
+ def push_update(self, filepath, commit_msg="Update"):
87
+ del filepath, commit_msg
88
+ print("🔒 Working-dataset publication is disabled; no file was uploaded.")
89
+ return {"status": "disabled", "uploaded": False}
90
+
91
+ def upload_audio_sample(self, audio_path, dialect):
92
+ del audio_path, dialect
93
+ print("🔒 Raw-audio publication is disabled; no file was uploaded.")
94
+ return None
test_groq_recovery.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression checks for Groq transport and the deployed free-only policy."""
2
+ import asyncio
3
+ import ast
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import urllib.error
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+ from src import inference_service as service
12
+
13
+
14
+ FREE_ENV = {"INFERENCE_FREE_ONLY": "true", "GROQ_API_KEY": "synthetic-groq"}
15
+
16
+
17
+ def test_free_auto_uses_groq_even_when_paid_keys_are_present():
18
+ env = {**FREE_ENV, "QWEN_API_KEY": "synthetic", "DEEPSEEK_API_KEY": "synthetic", "GOOGLE_API_KEY": "synthetic"}
19
+ with patch.dict(os.environ, env, clear=True), patch.object(service, "_post", return_value="Hello") as post:
20
+ result = service.generate_text("Synthetic prompt", "auto")
21
+ assert result["ok"] and result["route"] == "groq"
22
+ assert result["model"] == "qwen/qwen3.8-27b"
23
+ assert post.call_count == 1
24
+
25
+
26
+ @pytest.mark.parametrize("choice", ["qwen", "llama", "deepseek", "gemini", "nemotron", "gpt-oss", "lfm"])
27
+ def test_free_policy_rejects_paid_and_retired_choices(choice):
28
+ env = {**FREE_ENV, "QWEN_API_KEY": "synthetic", "OPENROUTER_API_KEY": "synthetic", "DEEPSEEK_API_KEY": "synthetic", "GOOGLE_API_KEY": "synthetic"}
29
+ with patch.dict(os.environ, env, clear=True), patch.object(service, "_post") as post:
30
+ result = service.generate_text("Synthetic prompt", choice)
31
+ assert result["error"]["code"] == "MODEL_UNAVAILABLE"
32
+ post.assert_not_called()
33
+
34
+
35
+ def test_free_auto_fallback_only_uses_the_free_router():
36
+ env = {**FREE_ENV, "OPENROUTER_API_KEY": "synthetic", "QWEN_API_KEY": "synthetic"}
37
+ with patch.dict(os.environ, env, clear=True), patch.object(service, "_post", side_effect=[TimeoutError(), "Hello"]) as post:
38
+ result = service.generate_text("Synthetic prompt", "auto")
39
+ assert result["route"] == "openrouter-free"
40
+ assert [call.args[0]["route"] for call in post.call_args_list] == ["groq", "openrouter-free"]
41
+
42
+
43
+ @pytest.mark.parametrize("override", [
44
+ {"GROQ_MODEL_NAME": "llama-3.3-70b-versatile"},
45
+ {"GROQ_BASE_URL": "https://other-provider.example/v1"},
46
+ {"OPENROUTER_FREE_MODEL_NAME": "deepseek/deepseek-v3.2"},
47
+ {"OPENROUTER_BASE_URL": "https://other-provider.example/v1"},
48
+ ])
49
+ def test_free_policy_rejects_provider_or_model_substitution(override):
50
+ env = {**FREE_ENV, "OPENROUTER_API_KEY": "synthetic", **override}
51
+ choice = "groq" if any(key.startswith("GROQ_") for key in override) else "openrouter-free"
52
+ with patch.dict(os.environ, env, clear=True), patch.object(service, "_post") as post:
53
+ assert not service.generate_text("Synthetic prompt", choice)["ok"]
54
+ post.assert_not_called()
55
+
56
+
57
+ def test_transport_has_application_user_agent_and_uses_instruct_mode():
58
+ payload = {"choices": [{"finish_reason": "stop", "message": {"content": "Hello"}}]}
59
+ with patch.dict(os.environ, FREE_ENV, clear=True), patch.object(service.urllib.request, "build_opener") as opener:
60
+ opener.return_value.open.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode()
61
+ assert service.generate_text("Synthetic prompt", "groq")["ok"]
62
+ request = opener.return_value.open.call_args.args[0]
63
+ assert request.get_header("User-agent") == "PureDialectApps/1.0"
64
+ assert json.loads(request.data)["reasoning_effort"] == "none"
65
+ assert json.loads(request.data)["model"] == "qwen/qwen3.8-27b"
66
+
67
+
68
+ @pytest.mark.parametrize("status, expected", [(401, "PROVIDER_AUTH_FAILED"), (403, "PROVIDER_ACCESS_DENIED")])
69
+ def test_credentials_and_permissions_are_distinct_and_redacted(status, expected):
70
+ error = urllib.error.HTTPError("https://api.groq.com", status, "private provider text", {}, None)
71
+ with patch.dict(os.environ, FREE_ENV, clear=True), patch.object(service, "_post", side_effect=error):
72
+ result = service.generate_text("Synthetic prompt", "groq")
73
+ assert result["error"]["code"] == expected
74
+ assert "private" not in json.dumps(result)
75
+ assert "synthetic-groq" not in json.dumps(result)
76
+
77
+
78
+ def test_legacy_manager_uses_the_shared_policy_adapter():
79
+ path = Path(__file__).parent / "src" / "managers.py"
80
+ tree = ast.parse(path.read_text(encoding="utf-8"))
81
+ manager = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "GroqManager")
82
+ calls = []
83
+ def generate(prompt, choice):
84
+ calls.append((prompt, choice))
85
+ return {"ok": True, "text": "Hello", "model": "qwen/qwen3.8-27b", "route": "groq"}
86
+ namespace = {"os": os, "asyncio": asyncio, "generate_text": generate}
87
+ exec(compile(ast.Module(body=[manager], type_ignores=[]), str(path), "exec"), namespace)
88
+ owner = namespace["GroqManager"]("synthetic")
89
+ result = asyncio.run(owner.generate_fast("Synthetic prompt"))
90
+ assert result.text == "Hello"
91
+ assert calls == [("Synthetic prompt", "groq")]
92
+ assert owner.last_used_model == "qwen/qwen3.8-27b"