Spaces:
Sleeping
Sleeping
GitHub Actions commited on
Commit ·
2d9f3fa
1
Parent(s): 0fab727
Deploy backend from GitHub
Browse files- INFERENCE_CONFIG.md +28 -0
- requirements.txt +49 -49
- src/inference_service.py +174 -174
- src/managers.py +94 -94
- test_groq_recovery.py +92 -0
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"
|