From a2320ecdc84187fe0db9a4c5a8f4e55f8ec90bf4 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Thu, 10 Sep 2026 09:02:02 +0200 Subject: [PATCH] add DeepSeek AI provider --- .env.example | 6 +++--- README.md | 6 +++--- api/server.py | 51 ++++++++++++++++++++++++++++++++--------------- tests/test_app.py | 20 +++++++++++++++++++ web/app.js | 4 ++-- 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index eba1d0f..db4e8d6 100644 --- a/.env.example +++ b/.env.example @@ -46,10 +46,10 @@ BACKUP_DIR=/data/backups BACKUP_RETENTION=14 BACKUP_INTERVAL_HOURS=24 -# AI provider for analysis and tailored drafts: nous or custom. +# AI provider for analysis and tailored drafts: nous, deepseek, or custom. AI_PROVIDER=custom -# For Nous, use https://portal.nousresearch.com to create an API key; -# the app uses https://inference-api.nousresearch.com/v1 automatically. +# DeepSeek uses https://api.deepseek.com/v1 with model deepseek-chat by default. +# For Nous, use https://portal.nousresearch.com to create an API key. AI_BASE_URL= AI_API_KEY= AI_MODEL= diff --git a/README.md b/README.md index ba4cb55..c857a13 100644 --- a/README.md +++ b/README.md @@ -126,10 +126,10 @@ error rather than remaining stuck forever. | `WEBSITE_SCAN_TIMEOUT` | `8` | Per-page public website/email scan timeout (2–60 seconds); can be changed in Settings | | `ANONYMIZED_TELEMETRY` | `false` | Keeps Browser Use telemetry disabled in this personal deployment | | `LOG_RETENTION` | `20000` | Maximum operational log entries retained in SQLite | -| `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_PROVIDER` | `custom` | Set to `nous`, `deepseek`, or `custom` for an OpenAI-compatible endpoint | +| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint; DeepSeek uses `https://api.deepseek.com/v1` automatically | +| `AI_MODEL` | empty | Model name; DeepSeek defaults to `deepseek-chat` | | `AI_API_KEY` | empty | Optional provider key; keep server-side | -| `AI_MODEL` | empty | Model name used for evidence-grounded drafts | | `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) | ### Nous Research Portal diff --git a/api/server.py b/api/server.py index ddc4cfe..11b150b 100644 --- a/api/server.py +++ b/api/server.py @@ -83,6 +83,11 @@ try: except (TypeError, ValueError): BACKUP_INTERVAL_HOURS = 24 NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1" +DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" +DEEPSEEK_MODELS = [ + {"id": "deepseek-chat", "label": "DeepSeek Chat", "free": False}, + {"id": "deepseek-reasoner", "label": "DeepSeek Reasoner", "free": False}, +] NOUS_FREE_MODELS = [ {"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True}, {"id": "upstage/solar-pro-4:free", "label": "Upstage · Solar Pro 4", "free": True}, @@ -1412,6 +1417,14 @@ def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, st raise RuntimeError(f"{error_prefix}_unreachable:{exc.reason}") from exc +def ai_requires_api_key(provider: str) -> bool: + return str(provider or "").strip().lower() in {"nous", "deepseek"} + + +def ai_is_configured(runtime: dict[str, str]) -> bool: + return bool(runtime.get("base_url") and runtime.get("model") and (not ai_requires_api_key(runtime.get("provider", "")) or runtime.get("api_key"))) + + def ai_runtime_settings() -> dict[str, str]: saved: dict[str, str] = {} try: @@ -1422,10 +1435,14 @@ def ai_runtime_settings() -> dict[str, str]: pass provider = saved.get("AI_PROVIDER", AI_PROVIDER).strip().lower() or "custom" base = saved.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/") + model = saved.get("AI_MODEL", AI_MODEL).strip() if provider == "nous": base = NOUS_BASE_URL + elif provider == "deepseek": + base = DEEPSEEK_BASE_URL + model = model or "deepseek-chat" mockup_timeout = _setting_int(saved, "AI_MOCKUP_TIMEOUT_SECONDS", "AI_MOCKUP_TIMEOUT_SECONDS", AI_MOCKUP_TIMEOUT_SECONDS, 20, 300) - return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": saved.get("AI_MODEL", AI_MODEL).strip(), "mockup_timeout_seconds": str(mockup_timeout)} + return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": model, "mockup_timeout_seconds": str(mockup_timeout)} def nous_models() -> list[dict[str, Any]]: @@ -1980,8 +1997,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" ai_base_url = runtime["base_url"] ai_api_key = runtime["api_key"] ai_model = runtime["model"] - if runtime["provider"] == "nous" and not ai_api_key: - raise RuntimeError("nous_api_key_required_for_standalone_app") + if ai_requires_api_key(runtime["provider"]) and not ai_api_key: + raise RuntimeError("ai_provider_api_key_required") if not ai_base_url or not ai_model: raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL") evidence_ids = ["prospect_record"] @@ -2412,8 +2429,8 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me ai_base_url = runtime["base_url"] ai_api_key = runtime["api_key"] ai_model = runtime["model"] - if runtime["provider"] == "nous" and not ai_api_key: - raise RuntimeError("nous_api_key_required_for_standalone_app") + if ai_requires_api_key(runtime["provider"]) and not ai_api_key: + raise RuntimeError("ai_provider_api_key_required") if not ai_base_url or not ai_model: raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL") current_subject = str(current_subject or "").strip()[:300] @@ -2551,8 +2568,8 @@ def _parse_ai_object(content: Any) -> dict[str, Any]: def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]: """Assess whether a prospect is worth human review/contact.""" runtime = ai_runtime_settings() - if runtime["provider"] == "nous" and not runtime["api_key"]: - raise RuntimeError("nous_api_key_required_for_standalone_app") + if ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]: + raise RuntimeError("ai_provider_api_key_required") if not runtime["base_url"] or not runtime["model"]: raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL") profile = company_profile_settings() @@ -2719,7 +2736,7 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No snapshot = snapshot or {} runtime = ai_runtime_settings() fallback = fallback_mockup_html(prospect, snapshot) - if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]): + if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]): return fallback, "fallback", "" source = { "business_name": _mockup_text(prospect.get("name"), 160), @@ -2779,7 +2796,7 @@ def generate_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | N return {"home": html}, source, model fallback = fallback_mockup_pages(prospect, snapshot) runtime = ai_runtime_settings() - if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]): + if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]): return fallback, "fallback", "" source = { "business_name": _mockup_text(prospect.get("name"), 160), @@ -3033,7 +3050,7 @@ def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] | requested_design = _premium_design_from_instructions(instructions) fallback = premium_render_pages(prospect, snapshot, mode=mode, design=requested_design) runtime = ai_runtime_settings() - if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]): + if not runtime["base_url"] or not runtime["model"] or (ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]): return fallback, "fallback", "" source = { "business_name": _mockup_text(prospect.get("name"), 160), @@ -3198,12 +3215,14 @@ class Handler(BaseHTTPRequestHandler): "email_extraction_always": True, "browser_use_enabled": browser_runtime["enabled"], "ai_provider": ai_runtime["provider"], - "ai_configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), + "ai_configured": ai_is_configured(ai_runtime), }) if parsed.path == "/api/ai/models": provider = (parse_qs(parsed.query).get("provider", [AI_PROVIDER])[0] or AI_PROVIDER).strip().lower() if provider == "nous": return self.send_json(200, {"provider": "nous", "base_url": NOUS_BASE_URL, "models": nous_models()}) + if provider == "deepseek": + return self.send_json(200, {"provider": "deepseek", "base_url": DEEPSEEK_BASE_URL, "models": DEEPSEEK_MODELS}) return self.send_json(200, {"provider": provider or "custom", "models": []}) if parsed.path == "/api/logs": params = parse_qs(parsed.query) @@ -3241,7 +3260,7 @@ class Handler(BaseHTTPRequestHandler): if parsed.path == "/api/settings": saved = _saved_settings() ai_runtime = ai_runtime_settings() - models = nous_models() if ai_runtime["provider"] == "nous" else [] + models = nous_models() if ai_runtime["provider"] == "nous" else DEEPSEEK_MODELS if ai_runtime["provider"] == "deepseek" else [] browser_runtime = browser_use_settings() maps_runtime = gmaps_runtime_settings() scan_timeout = website_scan_timeout_seconds() @@ -3251,7 +3270,7 @@ class Handler(BaseHTTPRequestHandler): "ready": backend_mode() == "browser" or bool(GMAPS_URL), **maps_runtime, }, - "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, + "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": ai_is_configured(ai_runtime), "models": models}, "browser_use": browser_runtime, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout, "ai_mockup_timeout_seconds": int(ai_runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS)}, @@ -3702,7 +3721,7 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(200, { "saved": True, "saved_keys": saved_keys, - "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"]))}, + "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": ai_is_configured(ai_runtime)}, "browser_use": browser_use_settings(), "maps": gmaps_runtime_settings(), "limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds(), "ai_mockup_timeout_seconds": int(ai_runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS)}, @@ -3854,8 +3873,8 @@ class Handler(BaseHTTPRequestHandler): runtime = ai_runtime_settings() if not runtime["base_url"] or not runtime["model"]: return self.send_json(400, {"error": "ai_not_configured_set_provider_and_model"}) - if runtime["provider"] == "nous" and not runtime["api_key"]: - return self.send_json(400, {"error": "nous_api_key_required_for_standalone_app"}) + if ai_requires_api_key(runtime["provider"]) and not runtime["api_key"]: + return self.send_json(400, {"error": "ai_provider_api_key_required"}) 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, error_prefix="ai") content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else "" diff --git a/tests/test_app.py b/tests/test_app.py index 086bef5..94db8b5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -123,6 +123,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_deepseek_provider_uses_fixed_endpoint_and_default_models(self): + status, body = self.request("GET", "/api/ai/models?provider=deepseek") + self.assertEqual(status, 200) + self.assertEqual(body["base_url"], "https://api.deepseek.com/v1") + self.assertEqual([item["id"] for item in body["models"]], ["deepseek-chat", "deepseek-reasoner"]) + status, body = self.request("POST", "/api/settings", {"ai": {"AI_PROVIDER": "deepseek", "AI_API_KEY": "deepseek-test-key"}}) + self.assertEqual(status, 200) + self.assertEqual(body["ai"]["base_url"], "https://api.deepseek.com/v1") + self.assertEqual(body["ai"]["model"], "deepseek-chat") + self.assertTrue(body["ai"]["configured"]) + status, body = self.request("GET", "/api/config") + self.assertEqual(status, 200) + self.assertEqual(body["ai_provider"], "deepseek") + self.assertNotIn("deepseek-test-key", json.dumps(body)) + with patch("api.server.http_json", return_value={"choices": [{"message": {"content": "OK"}}]}) as http_json: + status, body = self.request("POST", "/api/ai/test", {}) + self.assertEqual(status, 200) + self.assertTrue(body["ok"]) + self.assertEqual(http_json.call_args.args[0:2], ("POST", "https://api.deepseek.com/v1/chat/completions")) + def test_provider_errors_are_not_mislabelled_as_maps_errors(self): class CloudflareResponse: def read(self): diff --git a/web/app.js b/web/app.js index 9d481ee..ed55c1e 100644 --- a/web/app.js +++ b/web/app.js @@ -54,7 +54,7 @@ function renderSettings(){
-
AI PROVIDER

Draft assistant

Used when you assess a prospect or draft an email.

${ai.configured?'Configured':'Not configured'}

${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.'}

Nous model availability can change. Refresh Settings to update the free catalog.

+
AI PROVIDER

Draft assistant

Used when you assess a prospect or draft an email.

${ai.configured?'Configured':'Not configured'}

${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':provider==='deepseek'?'DeepSeek uses https://api.deepseek.com/v1 and requires a DeepSeek API key.':'Use any OpenAI-compatible /v1 endpoint.'}

DeepSeek defaults to deepseek-chat. Nous model availability can change. Refresh Settings to update catalogs.

SCHEDULES

Recurring discovery

Run bounded searches automatically; all results still arrive as needs-review.

${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)}${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'

No schedules configured.

'}
SAFETY

Suppression centre

Suppressed domains and emails are excluded before new prospects are saved.

${sup.map(s=>`
${esc(s.value)}${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'

No suppressions configured.

'}
`) @@ -82,7 +82,7 @@ function reviewStatusTabs(p){const options=[['pending','In review'],['approved', function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;const score=effectiveScore(p);const assessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${score}
${scoreLabel(p)}${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}
${reviewStatusTabs(p)}

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

${p.website?`Open website ↗`:''}
`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')} document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}}); document.addEventListener('click',async e=>{const button=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();const id=button.dataset.drawerReview,status=button.dataset.status;const panel=button.closest('.review-section');const buttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.disabled=true);try{const updated=await api(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]=updated;selected=updated;openDrawer(id);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){buttons.forEach(item=>item.disabled=false);if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}}); -document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>``).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}}); +document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}if(e.target.id==='aiProvider'){const provider=e.target.value;const fixed=provider==='nous'||provider==='deepseek';$('aiBase').readOnly=fixed;if(provider==='nous'){$('aiBase').value='https://inference-api.nousresearch.com/v1';}else if(provider==='deepseek'){$('aiBase').value='https://api.deepseek.com/v1';if(!$('aiModel').value||$('aiModel').value==='')$('aiModel').value='deepseek-chat';}const note=$('aiProviderNote');if(note)note.textContent=provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':provider==='deepseek'?'DeepSeek uses the official API endpoint and requires a DeepSeek API key.':'Use any OpenAI-compatible /v1 endpoint.';if(provider==='nous'||provider==='deepseek'){try{const data=await api('/api/ai/models?provider='+encodeURIComponent(provider));const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>``).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}}); document.addEventListener('input',e=>{if(e.target.id==='prospectSearch'&&$('prospectTable'))$('prospectTable').innerHTML=prospectTable()}); document.addEventListener('submit',async e=>{ if(e.target.id==='discoverForm'){