diff --git a/CHANGELOG.md b/CHANGELOG.md index 82dc36e5..c2f8bfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 外部占星服务额度用尽时马上说明,不再空等 + +解读若要用外部占星服务、而免费额度已经用完,会马上说明这次没拿到,不再空等几分钟。Skill 版本不变。 + ## 2026-09-15 — 打开星盘不再等外部占星服务,本地直接出盘 登录后打开星盘页,主盘按本站计算直接出来,不再先等外部服务。外部服务异常或断网时,星盘页仍然能看盘。Skill 版本不变。 diff --git a/deploy/patch_vedastro_update_check.py b/deploy/patch_vedastro_update_check.py new file mode 100644 index 00000000..5013d130 --- /dev/null +++ b/deploy/patch_vedastro_update_check.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Neutralize vedastro SDK import-time PyPI upgrade. Fail the image build if the hook is missing.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +NOOP_SOURCE = 'def check_for_update(package_name="vedastro"):\n return None\n' + + +def patch_update_check(path: Path) -> None: + text = path.read_text(encoding="utf-8") + if "def check_for_update" not in text: + raise SystemExit(f"{path} does not define check_for_update") + path.write_text(NOOP_SOURCE, encoding="utf-8") + + +def main() -> int: + spec = importlib.util.find_spec("vedastro.update_check") + if spec is None or not spec.origin: + print("vedastro.update_check is not installed", file=sys.stderr) + return 1 + path = Path(spec.origin) + patch_update_check(path) + print(f"neutralized {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/railway-api.Dockerfile b/deploy/railway-api.Dockerfile index 99ebe94d..b96fcc3e 100644 --- a/deploy/railway-api.Dockerfile +++ b/deploy/railway-api.Dockerfile @@ -6,11 +6,12 @@ ENV PYTHONUNBUFFERED=1 \ PIP_DEFAULT_TIMEOUT=60 WORKDIR /app -COPY requirements.txt ./ +COPY requirements.txt deploy/patch_vedastro_update_check.py ./ RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \ && apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update \ && apt-get install -y --no-install-recommends build-essential nodejs \ && python -m pip install -r requirements.txt \ + && python patch_vedastro_update_check.py \ && apt-get purge -y --auto-remove build-essential \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a0982f3b..0374bbf6 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11173,3 +11173,35 @@ - 相关记录:BUG-161、BUG-065、BUG-715、BUG-716、BUG-717、ERR-107、ERR-108、TASK-chart-page-blocking-open-20260915 - 复发自:BUG-161 - 修复版本:待发布 + +## BUG-719 | 官方 vedastro SDK 在 import 时联网自升级,运行期版本与 pin 不一致 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`vedastro` Python 包、`deploy/railway-api.Dockerfile`、bridge 子进程 +- 用户现象:镜像按 `requirements.txt` 固定版本安装,进程一启动却去访问 pypi,并可能把包升到更新版本。 +- 触发条件:`import vedastro`(每次 spawn `vedastro_python_bridge` 子进程都会发生)。 +- 根因:`vedastro/update_check.py` 在导出符号前请求 pypi 并 `pip install --upgrade`。包本身是 REST 客户端,不是本地计算库。 +- 修复:镜像在 `pip install` 之后运行 `deploy/patch_vedastro_update_check.py`,把 `check_for_update` 改成 no-op;hook 不存在则构建失败。 +- 验证:补丁单测(无网络)、Dockerfile 合同(install 之后必须跑 patch)、运行期版本等于 pin(本机若未装该包则 skip)。 +- 防复发:第三方 SDK 若在 import 期联网或改写环境,必须在镜像层中和,并用测试锁住 pin。 +- 相关记录:ERR-107、BUG-089 +- 复发自:无 +- 修复版本:待发布 + +## BUG-720 | 无 key 时 VedAstro 免费层同步排队可堵住前台线程数分钟 + +- 状态:investigating +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`scripts/vedastro_service_adapter.py` 的 `_acquire_free_tier_slot` +- 用户现象:外部证据路径可能卡住很久。是否在生产发生取决于 `VEDASTRO_API_KEY` 是否真的有值,产品负责人尚未回填 `docs/testing/vedastro-runtime-20260915.md`。 +- 触发条件:命中官方公共 endpoint 且没有 API key;一次 full snapshot 约 24 个请求,免费层默认 5 个/分钟。 +- 根因:名额耗尽时持进程级锁 `time.sleep()`,没有前台等待预算。 +- 修复:增加 `VEDASTRO_FREE_TIER_WAIT_BUDGET_SECONDS`,默认不超过 `VEDASTRO_TIMEOUT_SECONDS`。超出预算立即返回 `free_tier_rate_limited` 降级,不再无限等。生产是否仍会走到这条路径,等清单回填后更新本条。 +- 验证:预算为 0 时第二次请求不 sleep、返回体可区分限流降级;既有「窗口内等待一次」回归仍绿。 +- 防复发:前台同步路径不得无上限排队;不得靠调大免费层配额绕过第三方额度。 +- 相关记录:ERR-108、BUG-065、BUG-161、BUG-718 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-vedastro-runtime-ops-20260915.md b/docs/tasks/PROGRESS-vedastro-runtime-ops-20260915.md new file mode 100644 index 00000000..99e102be --- /dev/null +++ b/docs/tasks/PROGRESS-vedastro-runtime-ops-20260915.md @@ -0,0 +1,51 @@ +# PROGRESS · VedAstro 运行期真相(2026-09-15) + +工作树:`.worktrees/vedastro-runtime-ops-20260915` +分支:`codex/vedastro-runtime-ops-20260915` +基线:`origin/staging` @ `ce1939b0` +本机 Windows。Anaconda Python 3.11.7 + pytest 9.1.1。无 Docker。`python3` 是 Windows Store 桩(9009)。 + +未改 `scripts/jyotish_api_server.py`、未改 workflow、未 SSH 生产、未写入任何 key。Skill 未 bump。CHANGELOG 补了一句免费额度用尽时马上说明(用户可感知的是「不再空等」)。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 隔离 SDK 自更新 | 完成 | 镜像 `pip install` 后跑 `deploy/patch_vedastro_update_check.py`,失败即构建失败 | +| 2 免费层排队预算 | 完成 | 默认预算 ≤ `VEDASTRO_TIMEOUT_SECONDS`;超预算 fail-fast `free_tier_rate_limited`。生产是否走这条路径见任务 4 | +| 3 status 可观测 | 完成 | `/api/vedastro_gateway/status` 增加 endpoint/key 布尔、fanout、range scan、免费层是否 active、sdk_version、两份 TTL | +| 4 生产模式确认 | 环境缺口 | 清单 `docs/testing/vedastro-runtime-20260915.md`,只由产品负责人执行 | +| 5 Bug 历史 | 完成 | BUG-719 resolved;BUG-720 investigating(等任务 4 回填) | + +## 既有断言 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `test_post_json_with_retry_waits_for_free_tier_slot` | 窗口内第二次请求会 sleep | **未改**;预算默认等于 timeout,短等待仍发生 | 有预算的等待保留,只砍掉无上限排队 | + +## 反向验证 + +- 把 Dockerfile 里 `python patch_vedastro_update_check.py` 删掉,`test_dockerfile_patches_vedastro_update_check_after_install` 失败。 +- 把 `check_for_update` 从目标文件拿掉,`test_patch_fails_closed_when_hook_is_missing` 失败(`SystemExit`)。 +- 把 `birthPayload` 那一行与本单无关;本单不改前端。 + +## 耗时 + +本机未在镜像里跑 bridge 子进程(无 Docker 构建)。改前机制:每次 `import vedastro` 可能付 5 秒 pypi 超时,并可 `pip install --upgrade`。改后 `check_for_update` 为 no-op,不再发起该请求。镜像构建证据待有 Docker 的门禁/部署环境给出,不得写成已在本机验证。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `python -m pytest tests/test_vedastro_runtime_ops.py tests/test_vedastro_official_full_snapshot.py::test_post_json_with_retry_waits_for_free_tier_slot -v` | **7 passed / 1 skipped**(skip:本机未装 `vedastro`,运行期 pin 断言按任务书允许 skip) | +| `python -m pytest` 网关 status / 密钥不泄露 / chat runtime / 既有免费层等待 | 通过 | +| `tests/test_vedastro_gateway.py::test_gateway_completion_archives_official_raw_response` | **本机失败**(Windows `st_mode` 33206 vs `0o600`)。改前即如此,本单未动归档权限,不修 | +| `py_compile` 改动的三个 py 文件 | 通过 | +| `scripts/run_quality_gate.py --profile quick` | 本机未跑全量:无 `.venv`、无 Docker、`python3` 不可用。定向 VedAstro 回归如上 | +| `scripts/pre_work_check.py` | 本机 `python3` 是 Store 桩。未声称预检通过 | + +## 环境缺口 + +- 无 Docker:镜像层 patch 生效、运行期版本等于 pin,只能等门禁构建 / 部署后由产品负责人按清单第 5 条回填。 +- 生产 env(key / fanout / TTL)未查:见 `docs/testing/vedastro-runtime-20260915.md`。回填前不得声称生产行为已验证。 +- BUG-720 保持 `investigating`,直到清单回填。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f2bfefc6..8e4e4abe 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -233,7 +233,7 @@ | `TASK-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-staging-auto-migrate-on-deploy-20260915.md` | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待验收 | `codex/staging-auto-migrate-on-deploy-20260915` | | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | | `TASK-chart-vedastro-decouple-20260915.md` | `PROGRESS-chart-vedastro-decouple-20260915.md` | **P0**:星盘页首屏那一发 `/api/chart` 没传 `skip_vedastro_main_entry_overview`,实测冷算 0.40–0.66 秒里约 0.36 秒是 VedAstro 空转(本机连 endpoint 都没配);生产 env 开着 network + fanout,等于首屏同步等 24 个外部请求 + 3 次领域扫描,而 `chart-view-mapper.ts` / `chart-view-contract.ts` 根本不读这份证据。星历页同端点传了标志,两页策略相反。BUG-718,**复发自 BUG-161**(前台请求不得同步串联可选外部证据)。串行在 chart-page-blocking-open 之后 | 待验收 | `codex/chart-vedastro-decouple-20260915` | -| `TASK-vedastro-runtime-ops-20260915.md` | `PROGRESS-vedastro-runtime-ops-20260915.md` | 运行期真相单(与上单并行,文件不重叠;**不得改 `jyotish_api_server.py`**):官方 `vedastro==1.23.25` 其实是 REST 客户端(46 KB,全打 `api.vedastro.org`),且 import 时请求 pypi 并 `pip install --upgrade` 自升级——本机实测 pin 装完一 import 就变 1.23.26,`requirements.txt` 的锁在运行期是假的(BUG-719);无 key 时免费层排队是同步 sleep + 全局锁,24 个请求 ≈ 4.8 分钟堵住前台线程(BUG-720,定级依赖生产 key 是否配置)。生产 env 核对清单在 `docs/testing/vedastro-runtime-20260915.md`,**只能由产品负责人执行**。台账 ERR-107 / ERR-108 | 待领取 | — | +| `TASK-vedastro-runtime-ops-20260915.md` | `PROGRESS-vedastro-runtime-ops-20260915.md` | 运行期真相单(与上单并行,文件不重叠;**不得改 `jyotish_api_server.py`**):官方 `vedastro==1.23.25` 其实是 REST 客户端(46 KB,全打 `api.vedastro.org`),且 import 时请求 pypi 并 `pip install --upgrade` 自升级——本机实测 pin 装完一 import 就变 1.23.26,`requirements.txt` 的锁在运行期是假的(BUG-719);无 key 时免费层排队是同步 sleep + 全局锁,24 个请求 ≈ 4.8 分钟堵住前台线程(BUG-720,定级依赖生产 key 是否配置)。生产 env 核对清单在 `docs/testing/vedastro-runtime-20260915.md`,**只能由产品负责人执行**。台账 ERR-107 / ERR-108 | 待验收 | `codex/vedastro-runtime-ops-20260915` | ## 命名与归档 diff --git a/scripts/vedastro_gateway.py b/scripts/vedastro_gateway.py index 74d978d5..71395a50 100644 --- a/scripts/vedastro_gateway.py +++ b/scripts/vedastro_gateway.py @@ -29,6 +29,14 @@ def _official_network_enabled() -> bool: return os.environ.get("VEDASTRO_ENABLE_NETWORK", "1").strip().lower() in {"1", "true", "yes", "on"} +def _sdk_version() -> str | None: + try: + import importlib.metadata + return importlib.metadata.version("vedastro") + except Exception: + return None + + def _int_env(name: str, default: int = 0) -> int: raw = os.environ.get(name, "").strip() if not raw: @@ -271,7 +279,16 @@ def gateway_status() -> dict[str, Any]: "self_host_configured": config["self_host_endpoint_configured"], "official_configured": config["official_endpoint_configured"], "credential_configured": bool(os.environ.get("VEDASTRO_API_KEY", "").strip()), + "endpoint_configured": bool(config["official_endpoint_configured"] or config["self_host_endpoint_configured"]), + "fanout_enabled": os.environ.get("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1").strip().lower() in {"1", "true", "yes", "on"}, + "range_scan_network_enabled": os.environ.get("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"}, + "free_tier_queue_active": bool( + config["official_endpoint_configured"] + and not os.environ.get("VEDASTRO_API_KEY", "").strip() + ), + "sdk_version": _sdk_version(), "cache_ttl_seconds": config["cache_ttl_seconds"], + "official_full_snapshot_cache_ttl_seconds": _int_env("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", 0), "queue_enabled": config["queue_enabled"], "fail_open_local": config["fail_open_local"], "official_readiness": { diff --git a/scripts/vedastro_service_adapter.py b/scripts/vedastro_service_adapter.py index 22f5b309..40e47655 100644 --- a/scripts/vedastro_service_adapter.py +++ b/scripts/vedastro_service_adapter.py @@ -350,6 +350,7 @@ ALLOW_NETWORK_ENV = "VEDASTRO_ENABLE_NETWORK" CACHE_TTL_ENV = "VEDASTRO_CACHE_TTL_SECONDS" FREE_TIER_MAX_REQUESTS_ENV = "VEDASTRO_FREE_TIER_MAX_REQUESTS" FREE_TIER_WINDOW_SECONDS_ENV = "VEDASTRO_FREE_TIER_WINDOW_SECONDS" +FREE_TIER_WAIT_BUDGET_ENV = "VEDASTRO_FREE_TIER_WAIT_BUDGET_SECONDS" DEFAULT_CACHE_TTL_SECONDS = 86400.0 DEFAULT_FREE_TIER_MAX_REQUESTS = 5 DEFAULT_FREE_TIER_WINDOW_SECONDS = 60.0 @@ -505,6 +506,17 @@ def _free_tier_window_seconds() -> float: return DEFAULT_FREE_TIER_WINDOW_SECONDS +def _free_tier_wait_budget_seconds() -> float: + timeout = _timeout_seconds() + raw = os.environ.get(FREE_TIER_WAIT_BUDGET_ENV, "").strip() + if not raw: + return timeout + try: + return max(0.0, min(float(raw), timeout)) + except ValueError: + return timeout + + def schema() -> dict[str, Any]: request_example = { **PARITY_CASES["beijing_first_use_demo"], @@ -1903,6 +1915,7 @@ def _acquire_free_tier_slot(request_url: str) -> dict[str, Any]: } waited_seconds = 0.0 + budget = _free_tier_wait_budget_seconds() with _FREE_TIER_REQUEST_LOCK: while True: now = time.monotonic() @@ -1913,6 +1926,16 @@ def _acquire_free_tier_slot(request_url: str) -> dict[str, Any]: _FREE_TIER_REQUEST_TIMESTAMPS.append(now) break sleep_seconds = max(window_seconds - (now - _FREE_TIER_REQUEST_TIMESTAMPS[0]), 0.0) + if waited_seconds + sleep_seconds > budget: + return { + "mode": "free_tier_budget_exceeded", + "queue_active": False, + "waited_seconds": round(waited_seconds, 6), + "window_seconds": window_seconds, + "max_requests": max_requests, + "wait_budget_seconds": budget, + "degraded_reason": "free_tier_rate_limited", + } waited_seconds += sleep_seconds if sleep_seconds > 0: time.sleep(sleep_seconds) @@ -1963,6 +1986,15 @@ def _post_json_with_retry(endpoint: str, request_preview: dict[str, Any]) -> tup for attempt in range(1, max_attempts + 1): try: rate_limit_metadata = _acquire_free_tier_slot(request_url) + if rate_limit_metadata.get("mode") == "free_tier_budget_exceeded": + return { + "Status": "Fail", + "Payload": {"reason": "free_tier_rate_limited"}, + "source_metadata": { + "free_tier_rate_limit": rate_limit_metadata, + "degraded_reason": "free_tier_rate_limited", + }, + }, attempt, retry_error_codes payload = _post_json(endpoint, request_preview) if not isinstance(payload, dict): return {}, attempt, retry_error_codes @@ -2176,9 +2208,12 @@ def _official_snapshot_budget_exhausted_bundle(reason: str) -> dict[str, Any]: def _payload_status(payload: dict[str, Any]) -> str: if not isinstance(payload, dict): return "invalid" + metadata = payload.get("source_metadata") if isinstance(payload.get("source_metadata"), dict) else {} + if metadata.get("degraded_reason") == "free_tier_rate_limited": + return "rate_limited" if str(payload.get("Status") or "").lower() == "fail": failure_text = json.dumps(payload.get("Payload"), ensure_ascii=False).lower() - if "rate limit" in failure_text or "calls/minute" in failure_text or "too many requests" in failure_text: + if "rate limit" in failure_text or "calls/minute" in failure_text or "too many requests" in failure_text or "free_tier_rate_limited" in failure_text: return "rate_limited" return "ok" if payload.get("Status") == "Pass" else "fail" diff --git a/tests/test_vedastro_runtime_ops.py b/tests/test_vedastro_runtime_ops.py new file mode 100644 index 00000000..f1f2163e --- /dev/null +++ b/tests/test_vedastro_runtime_ops.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import importlib.metadata +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_patch_module(): + spec = importlib.util.spec_from_file_location( + "patch_vedastro_update_check", + ROOT / "deploy" / "patch_vedastro_update_check.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _pin_from_requirements() -> str: + for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines(): + if line.startswith("vedastro=="): + return line.split("==", 1)[1].strip() + raise AssertionError("requirements.txt does not pin vedastro") + + +def test_dockerfile_patches_vedastro_update_check_after_install() -> None: + dockerfile = (ROOT / "deploy" / "railway-api.Dockerfile").read_text(encoding="utf-8") + assert "python -m pip install -r requirements.txt" in dockerfile + assert "python patch_vedastro_update_check.py" in dockerfile + assert dockerfile.index("python -m pip install -r requirements.txt") < dockerfile.index( + "python patch_vedastro_update_check.py" + ) + + +def test_patch_neutralizes_check_for_update_without_network(tmp_path: Path) -> None: + patch_update_check = _load_patch_module().patch_update_check + + target = tmp_path / "update_check.py" + target.write_text( + "import requests\n" + "def check_for_update(package_name='vedastro'):\n" + " requests.get('https://pypi.org/pypi/vedastro/json')\n", + encoding="utf-8", + ) + patch_update_check(target) + namespace: dict[str, object] = {} + exec(target.read_text(encoding="utf-8"), namespace) + assert namespace["check_for_update"]("vedastro") is None + + +def test_patch_fails_closed_when_hook_is_missing(tmp_path: Path) -> None: + patch_update_check = _load_patch_module().patch_update_check + + target = tmp_path / "update_check.py" + target.write_text("def other():\n return 1\n", encoding="utf-8") + with pytest.raises(SystemExit): + patch_update_check(target) + + +def test_runtime_vedastro_version_matches_requirements_pin() -> None: + pin = _pin_from_requirements() + try: + installed = importlib.metadata.version("vedastro") + except importlib.metadata.PackageNotFoundError: + pytest.skip("vedastro is not installed in this environment") + assert installed == pin + + +def test_foreground_free_tier_queue_fail_fast_when_budget_exhausted(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + sleep_calls: list[float] = [] + monotonic_values = iter([0.0, 0.0, 0.1, 0.1]) + + monkeypatch.delenv("VEDASTRO_API_KEY", raising=False) + monkeypatch.setenv("VEDASTRO_FREE_TIER_MAX_REQUESTS", "1") + monkeypatch.setenv("VEDASTRO_FREE_TIER_WINDOW_SECONDS", "60") + monkeypatch.setenv("VEDASTRO_FREE_TIER_WAIT_BUDGET_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "20") + monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "0") + monkeypatch.setattr(adapter.time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(adapter.time, "sleep", lambda seconds: sleep_calls.append(seconds)) + monkeypatch.setattr( + adapter, + "_post_json", + lambda endpoint, preview: {"Status": "Pass", "Payload": {"ok": True}}, + ) + monkeypatch.setattr(adapter, "_FREE_TIER_REQUEST_TIMESTAMPS", []) + + first, _, _ = adapter._post_json_with_retry( + "https://api.vedastro.org/api", + {"operation": "range_scan", "official_request_profile": {"endpoint_path": "/Calculate/SearchEvents", "body": {"n": 1}}}, + ) + second, _, _ = adapter._post_json_with_retry( + "https://api.vedastro.org/api", + {"operation": "range_scan", "official_request_profile": {"endpoint_path": "/Calculate/SearchEvents", "body": {"n": 2}}}, + ) + + assert first["Status"] == "Pass" + assert second["Status"] == "Fail" + assert second["source_metadata"]["degraded_reason"] == "free_tier_rate_limited" + assert second["source_metadata"]["free_tier_rate_limit"]["mode"] == "free_tier_budget_exceeded" + assert adapter._payload_status(second) == "rate_limited" + assert sleep_calls == [] + + +def test_wait_budget_never_exceeds_vedastro_timeout(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "8") + monkeypatch.setenv("VEDASTRO_FREE_TIER_WAIT_BUDGET_SECONDS", "99") + assert adapter._free_tier_wait_budget_seconds() == 8.0 + + +def test_gateway_status_exposes_runtime_mode_without_secrets(monkeypatch) -> None: + from scripts import vedastro_gateway + + monkeypatch.setenv("JYOTISH_SKIP_LOCAL_ENV", "1") + monkeypatch.delenv("VEDASTRO_API_KEY", raising=False) + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://api.vedastro.org/api") + monkeypatch.setenv("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1") + monkeypatch.setenv("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "1") + monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "86400") + monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "120") + + status = vedastro_gateway.gateway_status() + text = json.dumps(status) + assert status["endpoint_configured"] is True + assert status["credential_configured"] is False + assert status["fanout_enabled"] is True + assert status["range_scan_network_enabled"] is True + assert status["free_tier_queue_active"] is True + assert "sdk_version" in status + assert status["cache_ttl_seconds"] == 86400 + assert status["official_full_snapshot_cache_ttl_seconds"] == 120 + assert "sk_live" not in text + + monkeypatch.setenv("VEDASTRO_API_KEY", "sk_live_should_never_appear") + with_key = vedastro_gateway.gateway_status() + dumped = json.dumps(with_key) + assert with_key["credential_configured"] is True + assert with_key["free_tier_queue_active"] is False + assert "sk_live_should_never_appear" not in dumped