deploy Nous v15 update

This commit is contained in:
Marco0300
2026-09-05 21:01:37 +02:00
parent 3127a97fb5
commit 53f93d45d2
5 changed files with 63 additions and 13 deletions
+4
View File
@@ -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;
+10
View File
@@ -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).
+27 -13
View File
@@ -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"})
+1
View File
@@ -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:-}
+21
View File
@@ -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()