From 53f93d45d2bb989a0a22e012ebcc79134fddc5bd Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Sat, 5 Sep 2026 21:01:37 +0200 Subject: [PATCH] deploy Nous v15 update --- .env.example | 4 ++++ README.md | 10 ++++++++++ api/server.py | 40 +++++++++++++++++++++++++++------------- docker-compose.yml | 1 + tests/test_app.py | 21 +++++++++++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 8d66861..c23da75 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ GMAPS_API_KEY= GMAPS_REQUEST_TIMEOUT=900 GMAPS_POLL_SECONDS=2 +# Identifies HTTP clients to providers that protect APIs with Cloudflare. +# Keep the default unless your provider asks for a different application name. +OUTBOUND_USER_AGENT=ProspectOS/2.0 (+personal-use) + # AI provider for analysis and tailored drafts: nous or custom. AI_PROVIDER=custom # For Nous, use https://portal.nousresearch.com to create an API key; diff --git a/README.md b/README.md index 5c06968..641866d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ error rather than remaining stuck forever. | `GMAPS_API_KEY` | empty | Not required in standard mode; only used in SaaS mode | | `GMAPS_REQUEST_TIMEOUT` | `900` | Maximum wait for the Maps worker in seconds; raise it for very large searches | | `GMAPS_POLL_SECONDS` | `2` | Poll interval while a job is running | +| `OUTBOUND_USER_AGENT` | `ProspectOS/2.0 (+personal-use)` | Identifies provider HTTP requests; avoids Python's default client fingerprint | | `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the bounded public-search fallback | | `AI_PROVIDER` | `custom` | Set to `nous` for the Nous Research Portal, or `custom` for another OpenAI-compatible endpoint | | `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint | @@ -61,6 +62,15 @@ the live `/models` catalog; if the catalog is unavailable it shows a stable fallback list of the free models currently published by Nous. Availability and model IDs can change, so refresh Settings periodically. +If a Nous request returns `ai_cloudflare_1010`, rebuild the app from the latest +source so provider requests include the explicit `ProspectOS/2.0` client +identity. The old build used Python's default `Python-urllib` fingerprint, which +can be rejected by the Portal's Cloudflare policy. This is not a Maps error and +it cannot be fixed by changing the model name. If the rebuilt app still receives +1010, test the same key from the server with the provider's documented +OpenAI-compatible endpoint and contact Nous support if the endpoint continues to +block that client; do not try to evade the security challenge. + The bundled fallback entries are LongCat 2.0, Solar Pro 4, Step 3.7 Flash, Laguna S 2.1, Laguna XS 2.1, Ling 3.0 Flash Fin, and Ling 3.0 Flash Sante (using the Portal's `:free` model suffix). diff --git a/api/server.py b/api/server.py index e2d1297..c5abc17 100644 --- a/api/server.py +++ b/api/server.py @@ -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/`` 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"}) diff --git a/docker-compose.yml b/docker-compose.yml index 473b630..6dbaeb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: GMAPS_API_KEY: ${GMAPS_API_KEY:-} GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900} GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2} + OUTBOUND_USER_AGENT: ${OUTBOUND_USER_AGENT:-ProspectOS/2.0 (+personal-use)} AI_PROVIDER: ${AI_PROVIDER:-custom} AI_BASE_URL: ${AI_BASE_URL:-} AI_API_KEY: ${AI_API_KEY:-} diff --git a/tests/test_app.py b/tests/test_app.py index 5856816..eea1d5c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,6 +3,7 @@ import os import tempfile import threading import time +import urllib.error import unittest from http.client import HTTPConnection from unittest.mock import patch @@ -116,6 +117,26 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertTrue(body["ok"]) self.assertEqual(http_json.call_args.args[0:2], ("POST", "https://inference-api.nousresearch.com/v1/chat/completions")) + def test_provider_errors_are_not_mislabelled_as_maps_errors(self): + class CloudflareResponse: + def read(self): + return b'{"error_code":1010,"detail":"blocked"}' + + def close(self): + return None + + code = 403 + + with patch("api.server.urllib.request.urlopen", side_effect=urllib.error.HTTPError("https://inference-api.nousresearch.com/v1/chat/completions", 403, "Forbidden", {}, CloudflareResponse())): + with self.assertRaisesRegex(RuntimeError, r"^ai_cloudflare_1010:"): + server.http_json("POST", "https://inference-api.nousresearch.com/v1/chat/completions", {}, error_prefix="ai") + + def test_provider_requests_send_explicit_user_agent(self): + response = type("Response", (), {"__enter__": lambda self: self, "__exit__": lambda self, *args: None, "read": lambda self: b"{}"})() + with patch("api.server.urllib.request.urlopen", return_value=response) as open_url: + server.http_json("GET", "https://example.test/v1/models", error_prefix="ai") + self.assertEqual(open_url.call_args.args[0].headers.get("User-agent"), server.OUTBOUND_USER_AGENT) + if __name__ == "__main__": unittest.main()