Prefer doclib API key config over env defaults

This commit is contained in:
jinzhenj
2026-07-02 19:32:34 +08:00
parent 121bbde5e1
commit e17a58533c
10 changed files with 79 additions and 34 deletions
+2 -2
View File
@@ -85,7 +85,7 @@ mineru parse <file> [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 <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 数据库设计概要
+2 -2
View File
@@ -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 命令:
+2 -2
View File
@@ -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 时,才可以走远端解析。
+1 -1
View File
@@ -41,7 +41,7 @@ mineru parse <file> [flags]
| Flag | 类型 | 说明 |
|------|------|------|
| `--remote` | bool | 显式启用远端解析;远端地址和 API Key 来自 config 或环境变量 |
| `--remote` | bool | 显式启用远端解析;远端地址来自 config,API Key 优先来自 config,未配置时使用环境变量 |
### 解析配置
+7 -6
View File
@@ -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` | 当前请求等待策略 |
+1 -1
View File
@@ -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`:
+2 -2
View File
@@ -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:
+19 -18
View File
@@ -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
@@ -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"))
@@ -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