diff --git a/NEXT-CLI.md b/NEXT-CLI.md index 0a43efca..28f37914 100644 --- a/NEXT-CLI.md +++ b/NEXT-CLI.md @@ -85,7 +85,7 @@ mineru parse [flags] | Flag | 类型 | 默认 | 说明 | |------|------|------|------| -| `--remote` | bool | false | 切换为远端模式。远端地址和 API Key 来自 config 或环境变量 | +| `--remote` | bool | false | 切换为远端模式。远端地址来自 config;API Key 优先来自 config,未配置时使用环境变量 | ### 解析配置 @@ -611,7 +611,7 @@ mineru config parse-server remote.api-key # 远程 API Key | `parse_server.local.self_hosted_url` | — | self_hosted 模式的 parse-server HTTP 地址 | | `parse_server.local.self_hosted_api_key` | — | self_hosted 模式的 API Key(可选) | | `parse_server.remote.url` | `https://mineru.net/api` | 远程 parse-server 地址 | -| `parse_server.remote.api_key` | — | 远程 API Key(环境变量 `MINERU_API_KEY` 也可设置)| +| `parse_server.remote.api_key` | — | 远程 API Key。未配置时可回退读取环境变量 `MINERU_API_KEY` | # mineru 数据库设计概要 diff --git a/NEXT-DESIGN.md b/NEXT-DESIGN.md index 36789a76..231412c6 100644 --- a/NEXT-DESIGN.md +++ b/NEXT-DESIGN.md @@ -778,7 +778,7 @@ config 表中的 parse-server 配置前缀为 `parse_server.*`: | `parse_server.local.self_hosted_url` | — | self_hosted 模式下 parse-server 的 HTTP 地址 | | `parse_server.local.self_hosted_api_key` | — | self_hosted 模式下的 API Key(可选) | | `parse_server.remote.url` | `https://mineru.net/api` | 远程 parse-server 地址 | -| `parse_server.remote.api_key` | — | 远程 API Key(从环境变量 `MINERU_API_KEY` 也可读取) | +| `parse_server.remote.api_key` | — | 远程 API Key。未配置时可回退读取环境变量 `MINERU_API_KEY` | **`local.mode` 行为**: @@ -788,7 +788,7 @@ config 表中的 parse-server 配置前缀为 `parse_server.*`: | `managed` | doclib 启动时自动拉起 parse-server 进程,停止时一并关闭。生命周期由 doclib 管理 | | `self_hosted` | 用户自行启动和管理 parse-server。doclib 启动时不自动拉起,仅通过配置的 URL 连接探活 | -**API Key 读取优先级**:环境变量 `MINERU_API_KEY` > config 中的 `remote.api_key` / `self_hosted_api_key`。 +**API Key 读取优先级**:config 中的 `remote.api_key` / `self_hosted_api_key` > 环境变量 `MINERU_API_KEY`。环境变量仅作为未配置 API Key 时的默认值。 CLI 命令: diff --git a/docs/next/architecture.md b/docs/next/architecture.md index 188d6909..9a40c150 100644 --- a/docs/next/architecture.md +++ b/docs/next/architecture.md @@ -385,9 +385,9 @@ parse-server 配置: | `parse_server.remote.url` | `https://mineru.net/api` | 远端地址 | | `parse_server.remote.api_key` | 无 | 远端 API Key | -API Key 优先级:环境变量 `MINERU_API_KEY` 高于 config 表中的 API Key。 +API Key 优先级:config 表中的 API Key 高于环境变量 `MINERU_API_KEY`;环境变量仅作为未配置 API Key 时的默认值。 -配置优先级统一为:CLI 参数 > 环境变量 > 文件配置 / SQLite 配置 > 内置默认值。启动前文件配置和 SQLite 配置不应定义同一配置项;需要在 doclib server 启动前确定的配置使用文件配置,运行时可变配置使用 SQLite。 +除 API Key 等运行时凭证另有说明外,配置优先级统一为:CLI 参数 > 环境变量 > 文件配置 / SQLite 配置 > 内置默认值。启动前文件配置和 SQLite 配置不应定义同一配置项;需要在 doclib server 启动前确定的配置使用文件配置,运行时可变配置使用 SQLite。 Watch、parsing-rules 和 exclude 规则也由 CLI 写入 SQLite。Parsing-rules 不得导致静默上传:只有规则显式允许 remote 时,才可以走远端解析。 diff --git a/docs/next/cli/mineru-parse.md b/docs/next/cli/mineru-parse.md index 7695caa4..56498e7c 100644 --- a/docs/next/cli/mineru-parse.md +++ b/docs/next/cli/mineru-parse.md @@ -41,7 +41,7 @@ mineru parse [flags] | Flag | 类型 | 说明 | |------|------|------| -| `--remote` | bool | 显式启用远端解析;远端地址和 API Key 来自 config 或环境变量 | +| `--remote` | bool | 显式启用远端解析;远端地址来自 config,API Key 优先来自 config,未配置时使用环境变量 | ### 解析配置 diff --git a/docs/next/config.md b/docs/next/config.md index 5d8dbd6a..92001c9a 100644 --- a/docs/next/config.md +++ b/docs/next/config.md @@ -89,12 +89,12 @@ MinerU 有两类配置: | `exclude_rules` 表 | exclude | 表结构 | 路径排除规则 | | `parsing_rules` 表 | parsing-rules | 表结构 | 路径解析规则 | | 当前命令显式参数 | `--tier pro`、`--remote` | 不持久化 | 当前请求覆盖 | -| 环境变量 | `MINERU_API_KEY` | 不持久化 | 临时凭证或 CI 覆盖 | +| 环境变量 | `MINERU_API_KEY` | 不持久化 | 临时凭证或 CI 默认值 | | SDK client 参数 | `base_url`、`api_key` | 调用方决定 | 当前 client 实例 | ## 3. 优先级 -运行时行为的优先级从高到低: +运行时行为的优先级从高到低。API Key 等运行时凭证的优先级见“API Key 与环境变量”。 1. 当前命令显式参数。 2. 当前进程环境变量。 @@ -153,10 +153,11 @@ parse-server 是 standard/pro 解析能力的来源。它可以是本地独立 API Key 读取优先级: -1. 环境变量 `MINERU_API_KEY`。 -2. SQLite config 中的 `parse_server.remote.api_key` 或 `parse_server.local.self_hosted_api_key`。 +1. SQLite config 中的 `parse_server.remote.api_key` 或 `parse_server.local.self_hosted_api_key`。 +2. 环境变量 `MINERU_API_KEY`,仅在对应 config API Key 未配置时作为默认值。 -环境变量适合无 tty 场景和 CI,但不应改变是否允许 remote 上传的判断。 +环境变量适合无 tty 场景和 CI 的默认凭证,但运行中的 doclib server 会优先使用 `mineru config set` +写入的运行时配置。环境变量不应改变是否允许 remote 上传的判断。 建议保留的环境变量: @@ -173,7 +174,7 @@ API Key 读取优先级: | CLI 参数 | 配置关系 | |----------|----------| | `--tier` | 覆盖当前请求 tier;未指定时使用默认选择策略 | -| `--remote` | 显式允许当前请求上传远端;远端 URL 和 API Key 来自 config 或环境变量 | +| `--remote` | 显式允许当前请求上传远端;远端 URL 来自 config,API Key 优先来自 config,未配置时使用环境变量 | | `--pages` | 当前请求页码范围 | | `--force` | 当前请求跳过 done 缓存;复用 active parse 或为未覆盖页创建新 parse;不删除或作废旧缓存 | | `--wait` / `--no-wait` | 当前请求等待策略 | diff --git a/docs/next/sdk/doclib-client.md b/docs/next/sdk/doclib-client.md index 70ddd9e2..9cb713d4 100644 --- a/docs/next/sdk/doclib-client.md +++ b/docs/next/sdk/doclib-client.md @@ -82,7 +82,7 @@ def parse( | `page_range` | 页码范围。 | | `format` | 返回格式,当前主要是 `markdown`。 | | `force` | 跳过已有 done 缓存并重新解析;可复用 active parse,只为未覆盖页创建新 parse。 | -| `remote` | 是否允许调用远端 parse-server。默认 `False`。远端 URL 和 API Key 来自 doclib config 或环境变量。 | +| `remote` | 是否允许调用远端 parse-server。默认 `False`。远端 URL 来自 doclib config;API Key 优先来自 doclib config,未配置时使用环境变量。 | 返回值当前是 dict,目标应稳定为 `ParseResponse`: diff --git a/mineru/doclib/services/parse_svc.py b/mineru/doclib/services/parse_svc.py index 041e14a5..e67f57f7 100644 --- a/mineru/doclib/services/parse_svc.py +++ b/mineru/doclib/services/parse_svc.py @@ -1096,7 +1096,7 @@ class ParseService: if privacy == "remote": url = cast(str, await self.config_svc.get("parse_server.remote.url")) - api_key = os.environ.get("MINERU_API_KEY") or (await self.config_svc.get("parse_server.remote.api_key")) + api_key = (await self.config_svc.get("parse_server.remote.api_key")) or None if not health.remote_healthy: # try fallback to local local_mode = (await self.config_svc.get("parse_server.local.mode")) or "disabled" @@ -1119,7 +1119,7 @@ class ParseService: if not health.local_healthy: raise ParseFailure("engine_unavailable", "Local parse-server is not ready. Please wait or check server status.") - api_key = os.environ.get("MINERU_API_KEY") or (await self.config_svc.get("parse_server.local.self_hosted_api_key")) + api_key = (await self.config_svc.get("parse_server.local.self_hosted_api_key")) or None return local_url, api_key, "local" async def _fail_task(self, task_id: int, code: str, message: str) -> None: diff --git a/mineru/parser/api_client.py b/mineru/parser/api_client.py index 462a1902..ccb878f0 100644 --- a/mineru/parser/api_client.py +++ b/mineru/parser/api_client.py @@ -48,6 +48,8 @@ class MinerUApiParser(DocumentParser): - ``page_range`` → per-file v1 ``page_range`` """ + DEFAULT_API_URL = "https://mineru.net/api" + def __init__( self, *, @@ -55,11 +57,10 @@ class MinerUApiParser(DocumentParser): api_key: str | None = None, tier: Tier | None = None, ) -> None: - api_url = api_url or os.environ.get("MINERU_API_URL", "https://mineru.net/api") - self._base = api_url.rstrip("/") - self._api_key = api_key - self._local = _is_local_network_url(self._base) - self._trust_env = should_trust_env_for_url(self._base) + self._base_url = (api_url or os.environ.get("MINERU_API_URL") or self.DEFAULT_API_URL).rstrip("/") + self._api_key = (api_key if api_key is not None else os.environ.get("MINERU_API_KEY")) or None + self._local = _is_local_network_url(self._base_url) + self._trust_env = should_trust_env_for_url(self._base_url) self.tier = tier # ── DocumentParser interface ───────────────────────────────────── @@ -106,7 +107,7 @@ class MinerUApiParser(DocumentParser): return payload def _output_formats(self) -> list[str]: - if "staging" in self._base: + if "staging" in self._base_url: # Staging still exposes the non-standard "json" output instead of middle_json/images. return ["json"] return ["middle_json", "images"] @@ -155,7 +156,7 @@ class MinerUApiParser(DocumentParser): with httpx.Client(timeout=httpx.Timeout(120, connect=30), trust_env=self._trust_env) as cli: r = cli.post( - f"{self._base}/v1/uploads", + f"{self._base_url}/v1/uploads", headers=self._headers(), json={ "filename": file_path.name, @@ -172,13 +173,13 @@ class MinerUApiParser(DocumentParser): upload_url = resp["upload_url"] upload_headers = resp.get("upload_headers", {}) if upload_url.startswith("/"): - upload_url = f"{self._base}{upload_url}" + upload_url = f"{self._base_url}{upload_url}" with file_path.open("rb") as fh: r2 = cli.put(upload_url, content=fh.read(), headers=upload_headers) self._check(r2) r3 = cli.post( - f"{self._base}/v1/uploads/{resp['id']}/complete", + f"{self._base_url}/v1/uploads/{resp['id']}/complete", headers=self._headers(), ) resp3 = self._check(r3) @@ -190,7 +191,7 @@ class MinerUApiParser(DocumentParser): async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=30), trust_env=self._trust_env) as cli: r = await cli.post( - f"{self._base}/v1/uploads", + f"{self._base_url}/v1/uploads", headers=self._headers(), json={ "filename": file_path.name, @@ -207,13 +208,13 @@ class MinerUApiParser(DocumentParser): upload_url = resp["upload_url"] upload_headers = resp.get("upload_headers", {}) if upload_url.startswith("/"): - upload_url = f"{self._base}{upload_url}" + upload_url = f"{self._base_url}{upload_url}" data = file_path.read_bytes() r2 = await cli.put(upload_url, content=data, headers=upload_headers) self._check(r2) r3 = await cli.post( - f"{self._base}/v1/uploads/{resp['id']}/complete", + f"{self._base_url}/v1/uploads/{resp['id']}/complete", headers=self._headers(), ) resp3 = self._check(r3) @@ -223,7 +224,7 @@ class MinerUApiParser(DocumentParser): def _do_parse(self, payload: dict[str, Any]) -> dict[str, Any]: with httpx.Client(timeout=httpx.Timeout(120, connect=30), trust_env=self._trust_env) as cli: - r = cli.post(f"{self._base}/v1/parse/jobs", headers=self._headers(), json=payload) + r = cli.post(f"{self._base_url}/v1/parse/jobs", headers=self._headers(), json=payload) job = self._check(r) if job.get("status") not in ("completed", "partial", "failed", "canceled"): # poll if wait timed out @@ -232,7 +233,7 @@ class MinerUApiParser(DocumentParser): async def _async_do_parse(self, payload: dict[str, Any]) -> dict[str, Any]: async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=30), trust_env=self._trust_env) as cli: - r = await cli.post(f"{self._base}/v1/parse/jobs", headers=self._headers(), json=payload) + r = await cli.post(f"{self._base_url}/v1/parse/jobs", headers=self._headers(), json=payload) job = self._check(r) if job.get("status") not in ("completed", "partial", "failed", "canceled"): job = await self._async_poll(cli, job["job_id"]) @@ -242,7 +243,7 @@ class MinerUApiParser(DocumentParser): delay = 2 for _ in range(150): # max 5 min time.sleep(delay) - r = cli.get(f"{self._base}/v1/parse/jobs/{job_id}", headers=self._headers()) + r = cli.get(f"{self._base_url}/v1/parse/jobs/{job_id}", headers=self._headers()) job = self._check(r) if job.get("status") in ("completed", "partial", "failed", "canceled"): return job @@ -253,7 +254,7 @@ class MinerUApiParser(DocumentParser): delay = 2 for _ in range(150): await asyncio.sleep(delay) - r = await cli.get(f"{self._base}/v1/parse/jobs/{job_id}", headers=self._headers()) + r = await cli.get(f"{self._base_url}/v1/parse/jobs/{job_id}", headers=self._headers()) job = self._check(r) if job.get("status") in ("completed", "partial", "failed", "canceled"): return job @@ -410,7 +411,7 @@ def _download_bytes(parser: MinerUApiParser, ref: dict[str, Any]) -> bytes: raise _V1APIError("invalid_response", "No file_id in output reference") with httpx.Client(timeout=httpx.Timeout(120, connect=30), follow_redirects=True, trust_env=parser._trust_env) as cli: - r = cli.get(f"{parser._base}/v1/files/{file_id}/content", headers=parser._headers()) + r = cli.get(f"{parser._base_url}/v1/files/{file_id}/content", headers=parser._headers()) _check_download_response(r) return r.content @@ -423,7 +424,7 @@ async def _async_download_bytes(parser: MinerUApiParser, ref: dict[str, Any]) -> async with httpx.AsyncClient( timeout=httpx.Timeout(120, connect=30), follow_redirects=True, trust_env=parser._trust_env ) as cli: - r = await cli.get(f"{parser._base}/v1/files/{file_id}/content", headers=parser._headers()) + r = await cli.get(f"{parser._base_url}/v1/files/{file_id}/content", headers=parser._headers()) _check_download_response(r) return r.content diff --git a/tests/unittest/test_doclib_cache_semantics.py b/tests/unittest/test_doclib_cache_semantics.py index f2024b47..3441005f 100644 --- a/tests/unittest/test_doclib_cache_semantics.py +++ b/tests/unittest/test_doclib_cache_semantics.py @@ -777,6 +777,41 @@ def test_config_service_accepts_valid_url_config_values(tmp_path: Path) -> None: asyncio.run(_run()) +def test_remote_api_target_prefers_config_api_key_over_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class _ConfigService: + async def get(self, key: str) -> str: + values = { + "parse_server.remote.url": "https://mineru.net/api", + "parse_server.remote.api_key": "config-key", + } + return values[key] + + async def _run() -> None: + db = DatabaseManager(str(tmp_path / "doclib.db")) + await db.initialize() + service = ParseService( + db=db, + fts=FTSManager(db), + config_svc=_ConfigService(), + data_dir=str(tmp_path / "data"), + parse_lock_timeout_sec=1800, + ) + + base_url, api_key, via = await service._resolve_api_target("remote", "pro") + + assert base_url == "https://mineru.net/api" + assert api_key == "config-key" + assert via == "remote" + + monkeypatch.setenv("MINERU_API_KEY", "env-key") + monkeypatch.setattr( + "mineru.doclib.background.parse_server_health.get_health", + lambda: SimpleNamespace(remote_healthy=True), + ) + + asyncio.run(_run()) + + def test_data_dir_is_not_runtime_kv_config(tmp_path: Path) -> None: async def _run() -> None: db = DatabaseManager(str(tmp_path / "doclib.db")) diff --git a/tests/unittest/test_parser_api_contract.py b/tests/unittest/test_parser_api_contract.py index 046a760a..47571e63 100644 --- a/tests/unittest/test_parser_api_contract.py +++ b/tests/unittest/test_parser_api_contract.py @@ -74,6 +74,14 @@ def test_api_client_uses_json_format_for_staging_compat() -> None: assert parser._output_formats() == ["json"] +def test_api_client_uses_env_api_key_when_no_api_key_argument(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINERU_API_KEY", "env-key") + + parser = MinerUApiParser(api_url="https://mineru.net/api", tier="pro") + + assert parser._headers()["Authorization"] == "Bearer env-key" + + class _FakeResponse: def __init__(self, status_code: int, payload: dict[str, object], text: str = "") -> None: self.status_code = status_code