deploy Nous v15 update
This commit is contained in:
@@ -12,6 +12,10 @@ GMAPS_API_KEY=
|
|||||||
GMAPS_REQUEST_TIMEOUT=900
|
GMAPS_REQUEST_TIMEOUT=900
|
||||||
GMAPS_POLL_SECONDS=2
|
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 for analysis and tailored drafts: nous or custom.
|
||||||
AI_PROVIDER=custom
|
AI_PROVIDER=custom
|
||||||
# For Nous, use https://portal.nousresearch.com to create an API key;
|
# For Nous, use https://portal.nousresearch.com to create an API key;
|
||||||
|
|||||||
@@ -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_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_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 |
|
| `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 |
|
| `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_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 |
|
| `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
|
fallback list of the free models currently published by Nous. Availability and
|
||||||
model IDs can change, so refresh Settings periodically.
|
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,
|
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
|
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).
|
(using the Portal's `:free` model suffix).
|
||||||
|
|||||||
+27
-13
@@ -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_API_KEY = os.environ.get("AI_API_KEY", "").strip()
|
||||||
AI_MODEL = os.environ.get("AI_MODEL", "").strip()
|
AI_MODEL = os.environ.get("AI_MODEL", "").strip()
|
||||||
AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower()
|
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_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||||
NOUS_FREE_MODELS = [
|
NOUS_FREE_MODELS = [
|
||||||
{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True},
|
{"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
|
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")
|
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:
|
if data is not None:
|
||||||
request_headers["Content-Type"] = "application/json"
|
request_headers["Content-Type"] = "application/json"
|
||||||
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
|
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()
|
body = response.read()
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
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:
|
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:
|
try:
|
||||||
return json.loads(body or b"{}")
|
return json.loads(body or b"{}")
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise RuntimeError("gmaps_invalid_json") from 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)."""
|
"""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")
|
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:
|
if data is not None:
|
||||||
request_headers["Content-Type"] = "application/json"
|
request_headers["Content-Type"] = "application/json"
|
||||||
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
|
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()
|
return response.read()
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
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:
|
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]:
|
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:
|
if time.monotonic() - _nous_models_cached_at < 600:
|
||||||
return _nous_models_cache
|
return _nous_models_cache
|
||||||
try:
|
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
|
raw = response.get("data", response) if isinstance(response, dict) else response
|
||||||
models: list[dict[str, Any]] = []
|
models: list[dict[str, Any]] = []
|
||||||
if isinstance(raw, list):
|
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",
|
GMAPS_URL + "/api/v1/jobs/" + quote_plus(str(job_id)) + "/download",
|
||||||
headers=gmaps_headers(),
|
headers=gmaps_headers(),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
error_prefix="gmaps",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
text = body.decode("utf-8-sig")
|
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),
|
"extra_reviews": bool(extra_reviews),
|
||||||
"max_time": max(60, min(3600, GMAPS_REQUEST_TIMEOUT)),
|
"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")
|
job_id = field_value(response, "job_id", "id", "JobID", "ID")
|
||||||
if not job_id:
|
if not job_id:
|
||||||
raise RuntimeError("gmaps_job_id_missing")
|
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_ok = {"ok", "completed", "succeeded", "success"}
|
||||||
terminal_bad = {"failed", "cancelled", "canceled", "discarded"}
|
terminal_bad = {"failed", "cancelled", "canceled", "discarded"}
|
||||||
while time.monotonic() < deadline:
|
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()
|
status = str(field_value(response, "status", "Status") or "pending").lower()
|
||||||
result_count = as_int(field_value(response, "result_count", "ResultCount"))
|
result_count = as_int(field_value(response, "result_count", "ResultCount"))
|
||||||
if progress:
|
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.
|
# requires strict JSON and the parser below validates the response.
|
||||||
if runtime["provider"] != "nous":
|
if runtime["provider"] != "nous":
|
||||||
request_payload["response_format"] = {"type": "json_object"}
|
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 ""
|
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
@@ -1184,7 +1198,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if runtime["provider"] == "nous" and not runtime["api_key"]:
|
if runtime["provider"] == "nous" and not runtime["api_key"]:
|
||||||
return self.send_json(400, {"error": "nous_api_key_required_for_standalone_app"})
|
return self.send_json(400, {"error": "nous_api_key_required_for_standalone_app"})
|
||||||
headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {}
|
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 ""
|
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
|
||||||
if not str(content).strip():
|
if not str(content).strip():
|
||||||
return self.send_json(502, {"error": "ai_empty_response"})
|
return self.send_json(502, {"error": "ai_empty_response"})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
GMAPS_API_KEY: ${GMAPS_API_KEY:-}
|
GMAPS_API_KEY: ${GMAPS_API_KEY:-}
|
||||||
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900}
|
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900}
|
||||||
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
|
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
|
||||||
|
OUTBOUND_USER_AGENT: ${OUTBOUND_USER_AGENT:-ProspectOS/2.0 (+personal-use)}
|
||||||
AI_PROVIDER: ${AI_PROVIDER:-custom}
|
AI_PROVIDER: ${AI_PROVIDER:-custom}
|
||||||
AI_BASE_URL: ${AI_BASE_URL:-}
|
AI_BASE_URL: ${AI_BASE_URL:-}
|
||||||
AI_API_KEY: ${AI_API_KEY:-}
|
AI_API_KEY: ${AI_API_KEY:-}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
import unittest
|
import unittest
|
||||||
from http.client import HTTPConnection
|
from http.client import HTTPConnection
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -116,6 +117,26 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
|||||||
self.assertTrue(body["ok"])
|
self.assertTrue(body["ok"])
|
||||||
self.assertEqual(http_json.call_args.args[0:2], ("POST", "https://inference-api.nousresearch.com/v1/chat/completions"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user