|
|
|
@@ -47,6 +47,11 @@ AI_BASE_URL = os.environ.get("AI_BASE_URL", "").strip().rstrip("/")
|
|
|
|
|
AI_API_KEY = os.environ.get("AI_API_KEY", "").strip()
|
|
|
|
|
AI_MODEL = os.environ.get("AI_MODEL", "").strip()
|
|
|
|
|
AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower()
|
|
|
|
|
# Cloudflare Browser Integrity checks can reject Python's default
|
|
|
|
|
# ``Python-urllib/<version>`` fingerprint before an OpenAI-compatible request
|
|
|
|
|
# reaches the provider. Identify this application consistently on provider
|
|
|
|
|
# requests instead of relying on that default.
|
|
|
|
|
OUTBOUND_USER_AGENT = os.environ.get("OUTBOUND_USER_AGENT", "ProspectOS/2.0 (+personal-use)").strip() or "ProspectOS/2.0 (+personal-use)"
|
|
|
|
|
NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
|
|
|
|
NOUS_FREE_MODELS = [
|
|
|
|
|
{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True},
|
|
|
|
@@ -455,9 +460,17 @@ def extract_gmaps_results(raw: Any) -> list[dict[str, Any]]:
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def http_json(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20) -> Any:
|
|
|
|
|
def _upstream_error(prefix: str, code: int, detail: str) -> str:
|
|
|
|
|
"""Return an actionable, provider-specific upstream error string."""
|
|
|
|
|
lowered = detail.lower()
|
|
|
|
|
if code == 403 and ("error 1010" in lowered or "error_code\":1010" in lowered or "error code: 1010" in lowered):
|
|
|
|
|
return f"{prefix}_cloudflare_1010:{detail}"
|
|
|
|
|
return f"{prefix}_http_{code}:{detail}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def http_json(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20, error_prefix: str = "upstream") -> Any:
|
|
|
|
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
|
|
|
request_headers = {"Accept": "application/json", **(headers or {})}
|
|
|
|
|
request_headers = {"Accept": "application/json", "User-Agent": OUTBOUND_USER_AGENT, **(headers or {})}
|
|
|
|
|
if data is not None:
|
|
|
|
|
request_headers["Content-Type"] = "application/json"
|
|
|
|
|
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
|
|
|
|
@@ -466,19 +479,19 @@ def http_json(method: str, url: str, payload: Any = None, headers: dict[str, str
|
|
|
|
|
body = response.read()
|
|
|
|
|
except urllib.error.HTTPError as exc:
|
|
|
|
|
detail = exc.read().decode("utf-8", "replace")[:300]
|
|
|
|
|
raise RuntimeError(f"gmaps_http_{exc.code}:{detail}") from exc
|
|
|
|
|
raise RuntimeError(_upstream_error(error_prefix, int(exc.code), detail)) from exc
|
|
|
|
|
except urllib.error.URLError as exc:
|
|
|
|
|
raise RuntimeError(f"gmaps_unreachable:{exc.reason}") from exc
|
|
|
|
|
raise RuntimeError(f"{error_prefix}_unreachable:{exc.reason}") from exc
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(body or b"{}")
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
raise RuntimeError("gmaps_invalid_json") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20) -> bytes:
|
|
|
|
|
def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, str] | None = None, timeout: float = 20, error_prefix: str = "upstream") -> bytes:
|
|
|
|
|
"""Make a small HTTP request where the response is not JSON (CSV download)."""
|
|
|
|
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
|
|
|
request_headers = {"Accept": "*/*", **(headers or {})}
|
|
|
|
|
request_headers = {"Accept": "*/*", "User-Agent": OUTBOUND_USER_AGENT, **(headers or {})}
|
|
|
|
|
if data is not None:
|
|
|
|
|
request_headers["Content-Type"] = "application/json"
|
|
|
|
|
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
|
|
|
|
@@ -487,9 +500,9 @@ def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, st
|
|
|
|
|
return response.read()
|
|
|
|
|
except urllib.error.HTTPError as exc:
|
|
|
|
|
detail = exc.read().decode("utf-8", "replace")[:300]
|
|
|
|
|
raise RuntimeError(f"gmaps_http_{exc.code}:{detail}") from exc
|
|
|
|
|
raise RuntimeError(_upstream_error(error_prefix, int(exc.code), detail)) from exc
|
|
|
|
|
except urllib.error.URLError as exc:
|
|
|
|
|
raise RuntimeError(f"gmaps_unreachable:{exc.reason}") from exc
|
|
|
|
|
raise RuntimeError(f"{error_prefix}_unreachable:{exc.reason}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ai_runtime_settings() -> dict[str, str]:
|
|
|
|
@@ -516,7 +529,7 @@ def nous_models() -> list[dict[str, Any]]:
|
|
|
|
|
if time.monotonic() - _nous_models_cached_at < 600:
|
|
|
|
|
return _nous_models_cache
|
|
|
|
|
try:
|
|
|
|
|
response = http_json("GET", NOUS_BASE_URL + "/models", timeout=10)
|
|
|
|
|
response = http_json("GET", NOUS_BASE_URL + "/models", timeout=10, error_prefix="ai")
|
|
|
|
|
raw = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
|
models: list[dict[str, Any]] = []
|
|
|
|
|
if isinstance(raw, list):
|
|
|
|
@@ -556,6 +569,7 @@ def download_standard_results(job_id: str) -> list[dict[str, Any]]:
|
|
|
|
|
GMAPS_URL + "/api/v1/jobs/" + quote_plus(str(job_id)) + "/download",
|
|
|
|
|
headers=gmaps_headers(),
|
|
|
|
|
timeout=30,
|
|
|
|
|
error_prefix="gmaps",
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
text = body.decode("utf-8-sig")
|
|
|
|
@@ -594,7 +608,7 @@ def submit_gmaps_job(query: str, include_email: bool = False, extra_reviews: boo
|
|
|
|
|
"extra_reviews": bool(extra_reviews),
|
|
|
|
|
"max_time": max(60, min(3600, GMAPS_REQUEST_TIMEOUT)),
|
|
|
|
|
}
|
|
|
|
|
response = http_json("POST", GMAPS_URL + endpoint, payload, gmaps_headers(), timeout=30)
|
|
|
|
|
response = http_json("POST", GMAPS_URL + endpoint, payload, gmaps_headers(), timeout=30, error_prefix="gmaps")
|
|
|
|
|
job_id = field_value(response, "job_id", "id", "JobID", "ID")
|
|
|
|
|
if not job_id:
|
|
|
|
|
raise RuntimeError("gmaps_job_id_missing")
|
|
|
|
@@ -611,7 +625,7 @@ def poll_gmaps_job(job_id: str, progress: Callable[[str, int, int], None] | None
|
|
|
|
|
terminal_ok = {"ok", "completed", "succeeded", "success"}
|
|
|
|
|
terminal_bad = {"failed", "cancelled", "canceled", "discarded"}
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
response = http_json("GET", GMAPS_URL + "/api/v1/jobs/" + quote_plus(str(job_id)), headers=gmaps_headers(), timeout=30)
|
|
|
|
|
response = http_json("GET", GMAPS_URL + "/api/v1/jobs/" + quote_plus(str(job_id)), headers=gmaps_headers(), timeout=30, error_prefix="gmaps")
|
|
|
|
|
status = str(field_value(response, "status", "Status") or "pending").lower()
|
|
|
|
|
result_count = as_int(field_value(response, "result_count", "ResultCount"))
|
|
|
|
|
if progress:
|
|
|
|
@@ -1018,7 +1032,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
|
|
|
|
|
# requires strict JSON and the parser below validates the response.
|
|
|
|
|
if runtime["provider"] != "nous":
|
|
|
|
|
request_payload["response_format"] = {"type": "json_object"}
|
|
|
|
|
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90)
|
|
|
|
|
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90, error_prefix="ai")
|
|
|
|
|
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(content)
|
|
|
|
@@ -1184,7 +1198,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
if runtime["provider"] == "nous" and not runtime["api_key"]:
|
|
|
|
|
return self.send_json(400, {"error": "nous_api_key_required_for_standalone_app"})
|
|
|
|
|
headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {}
|
|
|
|
|
result = http_json("POST", runtime["base_url"] + "/chat/completions", {"model": runtime["model"], "temperature": 0, "max_tokens": 8, "messages": [{"role": "user", "content": "Reply with exactly OK."}]}, headers=headers, timeout=30)
|
|
|
|
|
result = http_json("POST", runtime["base_url"] + "/chat/completions", {"model": runtime["model"], "temperature": 0, "max_tokens": 8, "messages": [{"role": "user", "content": "Reply with exactly OK."}]}, headers=headers, timeout=30, error_prefix="ai")
|
|
|
|
|
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
|
|
|
|
if not str(content).strip():
|
|
|
|
|
return self.send_json(502, {"error": "ai_empty_response"})
|
|
|
|
|