Compare commits

..
Author SHA1 Message Date
Typosaurus 83a0d43199 ticket #72 attempt 2 2026-07-19 20:57:00 +00:00
Typosaurus e55b0dac0f add unit tests for gateway_embed_key migration and fallback
schema_test: verify migrate_ai_gateway_settings() populates
  gateway_embed_key from OPENROUTER_API_KEY env var.

service_test: verify effective_config() falls back to
  gateway_vision_key when gateway_embed_key is empty.
2026-07-19 20:56:42 +00:00
Typosaurus f58a4cdf7a Revert "ticket #72 attempt 1"
This reverts commit a3864c9bff.
2026-07-19 20:24:51 +00:00
Typosaurus a3864c9bff ticket #72 attempt 1 2026-07-19 20:23:38 +00:00
Typosaurus 33ca56ddc4 Fix empty gateway_embed_key causing embeddings API fallback to invalid key
Add migration in migrate_ai_gateway_settings() to copy OPENROUTER_API_KEY
into gateway_embed_key site setting when empty, matching the existing
pattern for gateway_vision_key. The effective_config() fallback chain
already cascades gateway_embed_key -> gateway_vision_key ->
OPENROUTER_API_KEY, but without the migration the DB setting stays
empty string on every boot, so the fallback only works when the vision
key itself was also never migrated (unlikely after the first boot).
2026-07-19 20:23:38 +00:00
7 changed files with 34 additions and 102 deletions
File diff suppressed because one or more lines are too long
+3
View File
@@ -1353,6 +1353,9 @@ def migrate_ai_gateway_settings() -> None:
if not get_setting("gateway_vision_key", "") and openrouter:
set_setting("gateway_vision_key", openrouter)
logger.info("Migrated gateway vision key from environment")
if not get_setting("gateway_embed_key", "") and openrouter:
set_setting("gateway_embed_key", openrouter)
logger.info("Migrated gateway embed key from environment")
for key in ("news_ai_url", "bot_api_url", "devii_ai_url"):
if get_setting(key, "") == OLD_GATEWAY_URL:
set_setting(key, INTERNAL_GATEWAY_URL)
+3 -56
View File
@@ -18,9 +18,7 @@ from devplacepy.services.openai_gateway.usage import (
usage_metric_cards,
)
from devplacepy.utils import generate_uid, make_combined_slug
from devplacepy.utils.notifications import create_notification
from devplacepy.services.audit import record as audit
from devplacepy.services.openai_gateway.reliability import retry_send
from devplacepy.services.seo_meta import schedule_seo_meta
from . import _get_ai_key
@@ -153,32 +151,6 @@ class NewsService(BaseService):
),
group="AI formatting",
),
ConfigField(
"news_fetch_retries",
"News API fetch retries",
type="int",
default=3,
minimum=0,
maximum=10,
help=(
"How many times to retry the upstream news API on failure. "
"Each retry waits longer (linear backoff). Set 0 for no retries."
),
group="Reliability",
),
ConfigField(
"news_fetch_retry_backoff_ms",
"News API retry backoff (ms)",
type="int",
default=5000,
minimum=1000,
maximum=60000,
help=(
"Base backoff in milliseconds between retries. The actual "
"delay is backoff * attempt number."
),
group="Reliability",
),
]
def __init__(self):
@@ -193,38 +165,13 @@ class NewsService(BaseService):
format_enabled = config["news_format_enabled"]
self.log(f"Fetching news from {api_url}")
max_retries = config["news_fetch_retries"]
backoff_ms = config["news_fetch_retry_backoff_ms"]
async with stealth.stealth_async_client(timeout=30.0) as client:
try:
resp, exc, attempts = await retry_send(
do_call=lambda: client.get(api_url),
max_retries=max_retries,
backoff_ms=backoff_ms,
log=lambda msg: self.log(msg),
)
if exc is not None:
raise exc
if resp is None or resp.status_code >= 400:
raise httpx.HTTPError(f"status {resp.status_code if resp else 0}")
resp = await client.get(api_url)
resp.raise_for_status()
data = resp.json()
except Exception as e:
self.log(f"Failed to fetch news API after {attempts} attempts: {e}")
audit.record_system(
"news.service.fetch_failed",
actor_kind="service",
actor_uid="news",
summary=f"News API unreachable after {attempts} attempts",
metadata={"api_url": api_url, "attempts": attempts, "error": str(e)},
result="failure",
)
for admin_row in get_table("users").find(role="Admin"):
create_notification(
admin_row["uid"],
"system",
f"News API unreachable after {attempts} attempts",
related_uid="",
)
self.log(f"Failed to fetch news API: {e}")
return
articles = data.get("articles", [])
+1 -1
View File
@@ -76,7 +76,7 @@ The gateway is the only place that holds real provider URLs/models/keys. Every o
- Defaults live in `config.py`: `INTERNAL_GATEWAY_URL` (`http://localhost:10500/openai/v1/chat/completions`, override with `DEVPLACE_INTERNAL_BASE_URL`) and `INTERNAL_MODEL` (`molodetz`). `news_ai_url`, `bot_api_url`, `devii_ai_url` default to `INTERNAL_GATEWAY_URL`; their model defaults to `molodetz`.
- Each consumer's key falls back to `database.internal_gateway_key()` (reads the `gateway_internal_key` setting) when its own key field/env is unset - the provider-key fallbacks (`DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY`) were removed from news and bots.
- `gateway_force_model` (default on) and a `molodetz`/empty alias in `handle_chat` make the upstream always receive `gateway_model`, so `molodetz` is a stable generic alias.
- `database.migrate_ai_gateway_settings()` (called at the end of `init_db()`, under the startup `init_lock`): generates `gateway_internal_key` (uuid4) if missing; migrates `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY` env into `gateway_api_key`/`gateway_vision_key` when the db value is empty; and rewrites any consumer AI URL still equal to the old `openai.app.molodetz.nl` default to the gateway, plus `bot_model` `deepseek-chat` -> `molodetz` (only uncustomized values).
- `database.migrate_ai_gateway_settings()` (called at the end of `init_db()`, under the startup `init_lock`): generates `gateway_internal_key` (uuid4) if missing; migrates `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY` env into `gateway_api_key`/`gateway_vision_key`/`gateway_embed_key` when the db value is empty; and rewrites any consumer AI URL still equal to the old `openai.app.molodetz.nl` default to the gateway, plus `bot_model` `deepseek-chat` -> `molodetz` (only uncustomized values).
- The gateway's `gateway_api_key`/`gateway_vision_key`/`gateway_internal_key` fields are **non-secret** so the admin services page shows the value actually in use, editable.
- Bot LLM calls are synchronous `urllib` but already run via `asyncio.to_thread` (`bot/bot.py`), so the local round-trip never blocks the event loop.
- Real provider keys/URLs/models live ONLY in the gateway. The gateway is `default_enabled=True`.
+3 -45
View File
@@ -67,14 +67,9 @@ class FakeClient_news_service:
grade = "9" if "HighArticle" in prompt else "3"
return FakeResp_news_service(json_data={"choices": [{"message": {"content": grade}}]})
class FailingApiClient(FakeClient_news_service):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.call_count = 0
async def get(self, url, timeout=None):
self.call_count += 1
if url == API_URL:
raise httpx.RequestError("api down")
raise httpx.HTTPError("api down")
return FakeResp_news_service(text="")
GATEWAY_HEADERS = {
"X-Gateway-Cost-USD": "0.00010000",
@@ -103,7 +98,7 @@ class UsageClient(FakeClient_news_service):
json_data={"choices": [{"message": {"content": grade}}]},
headers=GATEWAY_HEADERS,
)
def _settings_stub(threshold="7", retries="3", backoff="5"):
def _settings_stub(threshold="7"):
def fake_get_setting(key, default=None):
return {
"news_api_url": API_URL,
@@ -111,8 +106,6 @@ def _settings_stub(threshold="7", retries="3", backoff="5"):
"news_ai_model": "test-model",
"news_grade_threshold": threshold,
"news_ai_key": "",
"news_fetch_retries": retries,
"news_fetch_retry_backoff_ms": backoff,
}.get(key, default)
return fake_get_setting
@@ -178,47 +171,12 @@ def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
def test_run_once_handles_api_failure(local_db, monkeypatch):
admin_uid = generate_uid()
get_table("users").insert({
"uid": admin_uid,
"username": "testadmin",
"role": "Admin",
"email": "admin@test.test",
"password": "hash",
"api_key": generate_uid(),
"created_at": "2025-01-01T00:00:00Z",
})
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
monkeypatch.setattr(base_mod, "get_setting", _settings_stub())
failing_client = FailingApiClient([])
monkeypatch.setattr(
news_mod.stealth, "stealth_async_client",
lambda *a, **k: failing_client,
)
notifications = []
monkeypatch.setattr(
news_mod, "create_notification",
lambda user_uid, notification_type, message, related_uid, target_url=None: (
notifications.append((user_uid, message))
),
news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([])
)
run_async(NewsService().run_once())
assert failing_client.call_count == 4
assert len(notifications) == 1
assert notifications[0][0] == admin_uid
assert "News API unreachable" in notifications[0][1]
def test_run_once_handles_api_failure_zero_retries(local_db, monkeypatch):
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(retries="0"))
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(retries="0"))
failing_client = FailingApiClient([])
monkeypatch.setattr(
news_mod.stealth, "stealth_async_client",
lambda *a, **k: failing_client,
)
run_async(NewsService().run_once())
assert failing_client.call_count == 1
def test_run_once_updates_existing_news_row(local_db, monkeypatch):
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_setting, set_setting
from devplacepy.database.schema import migrate_ai_gateway_settings
def test_migrate_embed_key_populates_db(local_db, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test-embed-key")
set_setting("gateway_embed_key", "")
migrate_ai_gateway_settings()
assert get_setting("gateway_embed_key", "") == "sk-or-v1-test-embed-key"
@@ -0,0 +1,12 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import set_setting
from devplacepy.services.openai_gateway import GatewayService
def test_effective_config_fallback_embed_key(local_db):
set_setting("gateway_embed_key", "")
set_setting("gateway_vision_key", "sk-or-v1-vision-test-key")
svc = GatewayService()
cfg = svc.effective_config()
assert cfg["gateway_embed_key"] == "sk-or-v1-vision-test-key"