CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
ob_ai_call is the only input to V2 pricing, and it was dropping the two numbers needed to interpret its own cost column. tokens_reasoning was captured from the provider response but never written to the table, so the cost of thinking mode was invisible once it left the process. Folding it into tokens_out was not an option either -- it is billed at the output rate, and mixing the two hides exactly the difference we now have to watch. rate_tier records which price list applied. Without it a row's cost_cents cannot be rechecked: DeepSeek's peak rate is double off-peak from 2026-08-17, so recomputing an old row at "the current rate" silently produces a different answer than what was actually billed. Migration 0002 adds both columns. tokens_reasoning defaults to 0 rather than NULL so cost aggregates do not collapse to NULL on historical rows; rate_tier stays nullable because rows written before this change genuinely have an unknown tier, and NULL says that more honestly than a guessed 'flat'. The smoke script now asserts both: reasoning tokens must be 0 (a non-zero value means a provider default re-enabled thinking mode behind our backs) and rate_tier must be present. Verified against the real API -- tokens_reasoning=0, rate_tier=off_peak, 3.9s round trip.
157 lines
7.2 KiB
Bash
Executable File
157 lines
7.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# End-to-end proof that the scaffold works against the REAL DeepSeek API.
|
||
#
|
||
# compose up → migrate → start API → dev token → POST /v1/agent/chat
|
||
# → print the ob_ai_call row and the bl_ledger rows
|
||
#
|
||
# This is the only script that spends real money. Everything in `make test` is
|
||
# hermetic.
|
||
set -Eeuo pipefail
|
||
|
||
cd "$(dirname "$0")/.."
|
||
|
||
COMPOSE="docker compose -f deploy/docker-compose.dev.yml"
|
||
API_PORT="${API_PORT:-8010}"
|
||
BASE="http://127.0.0.1:${API_PORT}"
|
||
LOG_DIR=".smoke"
|
||
API_LOG="${LOG_DIR}/api.log"
|
||
API_PID=""
|
||
|
||
bold() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
|
||
fail() { printf '\033[31mFAIL: %s\033[0m\n' "$1" >&2; exit 1; }
|
||
|
||
cleanup() {
|
||
if [[ -n "${API_PID}" ]] && kill -0 "${API_PID}" 2>/dev/null; then
|
||
kill "${API_PID}" 2>/dev/null || true
|
||
wait "${API_PID}" 2>/dev/null || true
|
||
fi
|
||
}
|
||
trap cleanup EXIT
|
||
|
||
mkdir -p "${LOG_DIR}"
|
||
|
||
[[ -f .env ]] || fail ".env not found. Copy .env.example to .env and set DEEPSEEK_API_KEY."
|
||
# 只取需要的这一个键,不 `source .env`:那些 *_JSON 值含双引号,
|
||
# 被 shell 解析后引号会消失,应用侧再读就成了非法 JSON。
|
||
DEEPSEEK_API_KEY=$(sed -n 's/^[[:space:]]*DEEPSEEK_API_KEY[[:space:]]*=[[:space:]]*//p' .env | tail -1)
|
||
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY%\"}; DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY#\"}
|
||
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY%\'}; DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY#\'}
|
||
[[ -n "${DEEPSEEK_API_KEY}" ]] || fail "DEEPSEEK_API_KEY is empty in .env"
|
||
[[ "${DEEPSEEK_API_KEY}" == sk-* ]] || fail "DEEPSEEK_API_KEY does not look like a real key"
|
||
|
||
bold "1/7 Starting PostgreSQL (pgvector) and Redis"
|
||
${COMPOSE} up -d postgres redis
|
||
for _ in $(seq 1 60); do
|
||
if ${COMPOSE} exec -T postgres pg_isready -U meida -d meida >/dev/null 2>&1; then break; fi
|
||
sleep 1
|
||
done
|
||
${COMPOSE} exec -T postgres pg_isready -U meida -d meida >/dev/null || fail "PostgreSQL never became ready"
|
||
${COMPOSE} exec -T redis redis-cli ping >/dev/null || fail "Redis never became ready"
|
||
echo "postgres + redis ready"
|
||
|
||
bold "2/7 Applying migrations"
|
||
uv run alembic upgrade head
|
||
${COMPOSE} exec -T postgres psql -U meida -d meida -c "\dt"
|
||
${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
|
||
"SELECT extname FROM pg_extension WHERE extname='vector'" | grep -q vector \
|
||
|| fail "pgvector extension missing"
|
||
echo "pgvector extension present"
|
||
|
||
bold "3/7 Starting the API"
|
||
uv run uvicorn apps.api.main:app --host 127.0.0.1 --port "${API_PORT}" >"${API_LOG}" 2>&1 &
|
||
API_PID=$!
|
||
for _ in $(seq 1 60); do
|
||
if curl -fsS "${BASE}/healthz" >/dev/null 2>&1; then break; fi
|
||
sleep 0.5
|
||
done
|
||
curl -fsS "${BASE}/healthz" >/dev/null || { cat "${API_LOG}"; fail "API did not start"; }
|
||
curl -fsS "${BASE}/readyz" | tee /dev/null >/dev/null || { cat "${API_LOG}"; fail "API not ready"; }
|
||
echo "api up on ${BASE} (logs: ${API_LOG})"
|
||
|
||
bold "4/7 Issuing a dev token"
|
||
DEVICE_KEY="smoke-$(date +%s)"
|
||
TOKEN_JSON=$(curl -fsS -X POST "${BASE}/v1/auth/dev-token" \
|
||
-H 'Content-Type: application/json' \
|
||
-d "{\"device_key\":\"${DEVICE_KEY}\"}")
|
||
TOKEN=$(printf '%s' "${TOKEN_JSON}" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["access_token"])')
|
||
USER_ID=$(printf '%s' "${TOKEN_JSON}" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["user_id"])')
|
||
[[ -n "${TOKEN}" ]] || fail "no access token issued"
|
||
echo "user_id=${USER_ID}"
|
||
|
||
bold "5/7 Calling POST /v1/agent/chat against the REAL DeepSeek API"
|
||
CHAT_JSON=$(curl -fsS -X POST "${BASE}/v1/agent/chat" \
|
||
-H 'Content-Type: application/json' \
|
||
-H "Authorization: Bearer ${TOKEN}" \
|
||
-d '{"message":"三十岁男生,第一次去互联网公司面试,穿什么比较稳妥?"}')
|
||
printf '%s\n' "${CHAT_JSON}" | python3 -m json.tool
|
||
# 走临时文件而不是管道:heredoc 会占用 stdin,管道里的 JSON 读不到
|
||
printf '%s' "${CHAT_JSON}" >"${LOG_DIR}/chat.json"
|
||
python3 - "${LOG_DIR}/chat.json" <<'PY'
|
||
import json
|
||
import sys
|
||
|
||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||
body = json.load(fh)
|
||
assert body["code"] == 0, body
|
||
data = body["data"]
|
||
assert data["reply"].strip(), "empty model reply"
|
||
usage = data["usage"]
|
||
|
||
print("\n--- MODEL REPLY -------------------------------------------------")
|
||
print(data["reply"])
|
||
print("-----------------------------------------------------------------")
|
||
print("provider={} model={} prompt={}@{}".format(
|
||
data["provider"], data["model"], data["prompt_id"], data["prompt_version"]))
|
||
print("latency={}ms tokens_in={} tokens_out={} cached={} cost_cents={}".format(
|
||
usage["latency_ms"], usage["tokens_in"], usage["tokens_out"],
|
||
usage["tokens_cached"], usage["cost_cents"]))
|
||
print("quota_remaining={}".format(data["quota_remaining"]))
|
||
|
||
# 对话必须在 NFR-PERF-02b 的整轮 15s 之内。这里只量单次 LLM 调用,
|
||
# 留出检索与后处理的余量,因此阈值取 10s。
|
||
assert usage["latency_ms"] < 10_000, f"LLM 调用耗时 {usage['latency_ms']}ms,超出预期"
|
||
PY
|
||
|
||
bold "6/7 ob_ai_call row (成本归因的唯一数据源, 架构文档 §13)"
|
||
${COMPOSE} exec -T postgres psql -U meida -d meida -x -c \
|
||
"SELECT call_id, user_id, scene, provider, model, prompt_id, prompt_version,
|
||
tokens_in, tokens_out, tokens_cached, tokens_reasoning,
|
||
latency_ms, cost_cents, rate_tier,
|
||
success, error_code, ref_type, created_at
|
||
FROM ob_ai_call
|
||
WHERE user_id = ${USER_ID}
|
||
ORDER BY created_at DESC"
|
||
ROWS=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
|
||
"SELECT count(*) FROM ob_ai_call WHERE user_id = ${USER_ID} AND success")
|
||
[[ "${ROWS// /}" -ge 1 ]] || fail "no successful ob_ai_call row was written"
|
||
|
||
# 思考模式若被上游默认打开,这里会立刻变成非 0 —— 延迟与输出侧成本会一起涨(T-P1-24a)。
|
||
REASONING=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
|
||
"SELECT coalesce(max(tokens_reasoning), 0) FROM ob_ai_call WHERE user_id = ${USER_ID}")
|
||
[[ "${REASONING// /}" == "0" ]] \
|
||
|| fail "思考模式 token 非 0(${REASONING}):V1 场景应显式关闭 thinking,请检查 routing 配置"
|
||
|
||
# 档位必须落库,否则事后无法复核 cost_cents 是按哪套单价算的(T-P1-25c)。
|
||
TIER=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
|
||
"SELECT coalesce(rate_tier, '') FROM ob_ai_call
|
||
WHERE user_id = ${USER_ID} ORDER BY created_at DESC LIMIT 1")
|
||
[[ -n "${TIER// /}" ]] || fail "ob_ai_call.rate_tier 为空:成本无法事后复核"
|
||
printf 'rate_tier=%s tokens_reasoning=%s\n' "${TIER// /}" "${REASONING// /}"
|
||
|
||
bold "7/7 bl_ledger rows (预扣三段式, 架构文档 §7.4)"
|
||
${COMPOSE} exec -T postgres psql -U meida -d meida -c \
|
||
"SELECT tx_id, delta, tx_type, status, quota_key, period_key, balance_after,
|
||
idempotency_key, created_at
|
||
FROM bl_ledger
|
||
WHERE user_id = ${USER_ID}
|
||
ORDER BY created_at"
|
||
COMMITTED=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
|
||
"SELECT count(*) FROM bl_ledger
|
||
WHERE user_id = ${USER_ID} AND tx_type='consume' AND status='committed'")
|
||
[[ "${COMMITTED// /}" -ge 1 ]] || fail "quota was not committed after a successful call"
|
||
|
||
${COMPOSE} exec -T postgres psql -U meida -d meida -c \
|
||
"SELECT quota_key, period_key, used, limit_value FROM bl_quota WHERE user_id = ${USER_ID}"
|
||
|
||
printf '\n\033[32mSMOKE PASSED\033[0m — real DeepSeek reply received, ob_ai_call and bl_ledger populated.\n'
|