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
12 changed files with 30 additions and 158 deletions
File diff suppressed because one or more lines are too long
+3 -13
View File
@@ -1070,7 +1070,6 @@ def init_db():
("planted_at", ""),
("ready_at", ""),
("watered_by", "[]"),
("cooldown_until", ""),
("created_at", ""),
("updated_at", ""),
):
@@ -1078,18 +1077,6 @@ def init_db():
game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
_drop_index(db, "idx_rate_limit_log_user_ts")
rate_limit_log = get_table("rate_limit_log")
for column, example in (
("uid", ""),
("user_uid", ""),
("action", ""),
("timestamp", ""),
):
if not rate_limit_log.has_column(column):
rate_limit_log.create_column_by_example(column, example)
_index(db, "rate_limit_log", "idx_rate_limit_log_user_ts", ["user_uid", "timestamp"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,
@@ -1366,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)
-31
View File
@@ -1,12 +1,10 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.database import db, get_table
from devplacepy.models import GameSlotForm
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameFarmViewOut
@@ -20,33 +18,6 @@ from devplacepy.utils import (
from ._shared import game_seo, notify_farm, owner_by_username
_MAX_ACTIONS_PER_MINUTE = 30
_RATE_WINDOW_SECONDS = 60
def _check_farm_rate_limit(user_uid: str) -> None:
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_RATE_WINDOW_SECONDS)).isoformat()
result = list(
db.query(
"SELECT COUNT(*) AS c FROM rate_limit_log"
" WHERE user_uid = :uid AND timestamp >= :cutoff",
uid=user_uid,
cutoff=cutoff,
)
)
count = result[0]["c"] if result else 0
if count >= _MAX_ACTIONS_PER_MINUTE:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Max 30 actions per minute.",
)
get_table("rate_limit_log").insert({
"user_uid": user_uid,
"action": "",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
router = APIRouter()
@@ -82,7 +53,6 @@ async def water_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()]
):
viewer = require_user(request)
_check_farm_rate_limit(viewer["uid"])
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
@@ -107,7 +77,6 @@ async def steal_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()]
):
viewer = require_user(request)
_check_farm_rate_limit(viewer["uid"])
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
+2 -31
View File
@@ -1,9 +1,8 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import (
@@ -13,7 +12,7 @@ from devplacepy.models import (
GameQuestForm,
GameSlotForm,
)
from devplacepy.database import db, get_table, mark_notifications_read_by_target
from devplacepy.database import mark_notifications_read_by_target
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
from devplacepy.services.game import GameError, store
@@ -21,33 +20,6 @@ from devplacepy.utils import award_rewards, get_current_user, require_user, trac
from ._shared import game_seo, notify_farm, state_payload
_MAX_ACTIONS_PER_MINUTE = 30
_RATE_WINDOW_SECONDS = 60
def _check_rate_limit(user_uid: str) -> None:
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_RATE_WINDOW_SECONDS)).isoformat()
result = list(
db.query(
"SELECT COUNT(*) AS c FROM rate_limit_log"
" WHERE user_uid = :uid AND timestamp >= :cutoff",
uid=user_uid,
cutoff=cutoff,
)
)
count = result[0]["c"] if result else 0
if count >= _MAX_ACTIONS_PER_MINUTE:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Max 30 actions per minute.",
)
get_table("rate_limit_log").insert({
"user_uid": user_uid,
"action": "",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
router = APIRouter()
@@ -86,7 +58,6 @@ async def game_leaderboard(request: Request):
async def _respond_action(request: Request, user: dict, fn, on_success=None):
_check_rate_limit(user["uid"])
try:
result = fn()
except GameError as exc:
@@ -60,18 +60,6 @@ CONFIRM_REQUIRED = {
"gateway_model_delete",
"email_account_delete",
"email_delete_message",
"game_buy_plot",
"game_claim_quest",
"game_daily",
"game_fertilize",
"game_harvest",
"game_plant",
"game_prestige",
"game_steal",
"game_upgrade_ci",
"game_upgrade_legacy",
"game_upgrade_perk",
"game_water",
}
CONDITIONAL_CONFIRM = {
@@ -39,9 +39,6 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
plot = _plot_at(farm["uid"], slot)
if not plot:
raise GameError("That plot does not exist.")
cooldown = plot.get("cooldown_until", "")
if cooldown and _now() < _parse_date(cooldown):
raise GameError("This plot is on cooldown. Wait a moment before planting.")
if plot.get("crop_key"):
raise GameError("That plot is already in use.")
coins = int(farm.get("coins", 0))
@@ -88,7 +85,6 @@ def harvest(user: dict, slot: int) -> dict:
"planted_at": "",
"ready_at": "",
"watered_by": "[]",
"cooldown_until": _iso(now + timedelta(seconds=5)),
"updated_at": _iso(now),
},
["uid"],
+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`.
-20
View File
@@ -177,23 +177,3 @@ def test_prestige_below_level_returns_400(app_server, seeded_db):
_reset_farm(name)
response = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
assert response.status_code == 400
def test_rate_limit_blocks_excess_requests(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
responses = []
for _ in range(31):
responses.append(
session.post(f"{BASE_URL}/game/buy-plot", headers=JSON)
)
success_count = sum(1 for r in responses[:30] if r.status_code != 429)
assert success_count >= 1, "expected at least one successful request"
assert responses[-1].status_code == 429, f"expected 429 on 31st request, got {responses[-1].status_code}"
assert "Rate limit exceeded" in responses[-1].json().get("detail", "")
# Verify the rate limit log stored entries for the test user
from devplacepy.database import get_table
user = get_table("users").find_one(username=name)
table = get_table("rate_limit_log")
entries = list(table.find(user_uid=user["uid"]))
assert len(entries) >= 2, f"expected rate log entries, got {len(entries)}"
-25
View File
@@ -212,28 +212,3 @@ def test_steal_cooldown_blocks_second_raid(app_server, seeded_db):
f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON
)
assert second.status_code == 400
def test_harvest_plant_cooldown_blocks_rapid_replant(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
# Plant and ripen a crop on slot 0
plant = session.post(
f"{BASE_URL}/game/plant", data={"slot": 0, "crop": "shell"}, headers=JSON
)
assert plant.status_code == 200
_ripen_owner_plot(name)
# Harvest it — this sets a 5-second cooldown on the plot
harvest = session.post(f"{BASE_URL}/game/harvest", data={"slot": 0}, headers=JSON)
assert harvest.status_code == 200
# Immediately replant on the same slot — must fail
replant = session.post(
f"{BASE_URL}/game/plant", data={"slot": 0, "crop": "shell"}, headers=JSON
)
assert replant.status_code == 400
assert "cooldown" in replant.text.lower()
# A different slot should still work
other = session.post(
f"{BASE_URL}/game/plant", data={"slot": 1, "crop": "shell"}, headers=JSON
)
assert other.status_code == 200
@@ -119,23 +119,3 @@ def test_game_actions_registered_with_correct_auth():
def test_game_read_actions_are_read_only():
for name in ("game_state", "game_leaderboard", "game_view_farm"):
assert BY_NAME[name].is_read_only is True
def test_all_game_post_actions_require_confirm():
mutating_game_actions = {
"game_plant",
"game_harvest",
"game_buy_plot",
"game_upgrade_ci",
"game_water",
"game_steal",
"game_fertilize",
"game_daily",
"game_upgrade_perk",
"game_claim_quest",
"game_prestige",
"game_upgrade_legacy",
}
for name in mutating_game_actions:
assert name in BY_NAME, f"{name} missing from catalog"
assert name in CONFIRM_REQUIRED, f"{name} missing from CONFIRM_REQUIRED"
@@ -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"