改用甲骨文机上已有的 ai-gateway (129.146.203.203:5100):它本身就 OpenAI 兼容,内部串联 nvidia/gemini/ollama 并轮换 4 个 Gemini key, 比在客户端自己串联更能吸收单厂商的配额和超时。回包里的 provider 字段透传为 meta.upstream,网关侧发生降级时前端也看得见。 fix(ai): 目录里两个 NVIDIA 模型 id 根本不存在 - qwen/qwen2.5-72b-instruct 和 deepseek-ai/deepseek-r1 是我凭印象写的, 实际 GET /v1/models 里没有,调用一律 404 - 改为该账号清单里确实存在的 nemotron-49b / mistral-large, 并在注释里写明 id 必须取自实时清单、不能猜 fix(ai): 请求被本机代理劫持导致网关不可达 - requests 默认读 HTTP_PROXY/ALL_PROXY,把发往甲骨文公网 IP 的请求 也塞进了 127.0.0.1:7897,120s 后超时 - 按 provider 区分:境外厂商(Gemini/NVIDIA)仍走代理,自建网关直连 (session.trust_env=False) fix(ai): 承诺的按模型裁剪从未实现 - 模块注释写着 payload 按 (模型窗口, 天数预算) 取小者裁剪,但实际是 用全局预算构建一次 prompt 发给链上所有模型;365 天数据对 Gemini 的 1M 窗口无碍,却会撑爆 128k 的模型 - 新增 max_days_for(),在循环内按各模型窗口分别构建 prompt fix(ai): 推理模型的思考过程吃光输出预算 - 网关首选 nemotron-3-ultra-550b 是推理模型,回答前先输出一段 chain-of-thought;默认 1024 tokens 全被思考占用,JSON 还没开始 就被截断 - max_tokens 改为可按 provider 声明,网关条目给 3000 fix(ai): 配置在 import 时被冻结 - DEFAULT_CHAIN/TIMEOUT/DAY_BUDGET 是模块级常量,改环境变量不生效, 且让开发机 .env 泄漏进测试进程(测试会读到真实 key 和链配置) - 改为 default_chain()/default_timeout()/default_day_budget() 按调用读取 - conftest 增加 autouse fixture 清空全部 AI_* 变量,测试不再继承 .env 测试 (184 passed, 1 skipped): - 新增 TestGatewayProvider: 透传 upstream、目标 URL/鉴权头、 token 失效时继续降级 - 新增 TestProxyPolicy: 境外厂商与自建端点的代理策略相反 - 新增 TestPerModelSizing: 128k 模型收到的 prompt 必须小于 1M 模型 - 新增 TestMaxTokens: 推理端点预算大于默认,且真正写进两种 payload - 新增 TestLazyConfig: 改环境变量立即生效 - mock 目标从 requests.post 改为 requests.Session.post 实测: 网关链路可返回合法 JSON,但 nemotron-550B 排队较久(约 160s), 故 AI_TIMEOUT_SECONDS 默认调到 180。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
123 lines
3.4 KiB
Python
123 lines
3.4 KiB
Python
"""
|
|
Shared pytest fixtures.
|
|
|
|
Environment must be configured BEFORE `config` is imported, because config.py
|
|
reads os.environ at import time. Each test then gets its own SQLite file so
|
|
tests never share state.
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
os.environ.setdefault("DB_TYPE", "sqlite")
|
|
os.environ.setdefault("JWT_SECRET", "test_secret_at_least_32_bytes_long_ok")
|
|
os.environ.setdefault("CORS_ORIGIN", "http://localhost:3000")
|
|
# Point at a throwaway path; the db_path fixture overrides it per test.
|
|
os.environ.setdefault(
|
|
"DATABASE_PATH", os.path.join(tempfile.mkdtemp(), "bootstrap.db")
|
|
)
|
|
|
|
import db as db_module # noqa: E402
|
|
from app import create_app # noqa: E402
|
|
|
|
# config.py calls load_dotenv() at import, so backend/.env leaks into the test
|
|
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change
|
|
# what the suite exercises (and could bill real API calls). Clear them here;
|
|
# individual tests opt back in through the `keys` / `gateway` fixtures.
|
|
_AI_ENV_VARS = (
|
|
"AI_MODEL_CHAIN",
|
|
"AI_DAY_BUDGET",
|
|
"AI_TIMEOUT_SECONDS",
|
|
"GEMINI_API_KEY",
|
|
"NVIDIA_API_KEY",
|
|
"NVIDIA_BASE_URL",
|
|
"AI_GATEWAY_TOKEN",
|
|
"AI_GATEWAY_BASE_URL",
|
|
"AI_GATEWAY_MODEL",
|
|
"OLLAMA_BASE_URL",
|
|
"OLLAMA_MODEL",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_ai_env(monkeypatch):
|
|
for var in _AI_ENV_VARS:
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path, monkeypatch):
|
|
"""A freshly initialized, isolated SQLite database for one test."""
|
|
path = str(tmp_path / "test.db")
|
|
monkeypatch.setattr(db_module, "SQLITE_PATH", path)
|
|
db_module.init_db()
|
|
return db_module
|
|
|
|
|
|
@pytest.fixture
|
|
def app(db):
|
|
application = create_app()
|
|
application.config.update(TESTING=True)
|
|
return application
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def user(client):
|
|
"""A registered user: returns {id, email, token, password}."""
|
|
password = "secret123"
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"email": "tester@example.com",
|
|
"garminEmail": "gm@example.com",
|
|
"garminPassword": password,
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.get_data(as_text=True)
|
|
body = resp.get_json()
|
|
return {**body, "password": password}
|
|
|
|
|
|
@pytest.fixture
|
|
def auth(user):
|
|
"""Authorization headers for the registered user."""
|
|
return {"Authorization": f"Bearer {user['token']}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def seed_health(db, user):
|
|
"""Insert daily health rows. Returns the inserted records."""
|
|
|
|
def _seed(records):
|
|
for r in records:
|
|
db.execute(
|
|
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
|
|
"heart_rate_variability, sleep_duration, sleep_quality, stress, "
|
|
"calories_burned) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
[
|
|
f"{user['id']}-{r['date']}",
|
|
user["id"],
|
|
r["date"],
|
|
r.get("steps"),
|
|
r.get("heart_rate"),
|
|
r.get("hrv"),
|
|
r.get("sleep_duration"),
|
|
r.get("sleep_quality"),
|
|
r.get("stress"),
|
|
r.get("calories"),
|
|
],
|
|
)
|
|
return records
|
|
|
|
return _seed
|