Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,14 @@ poolctl daemon restart
poolctl daemon status
```

multica 只能通过 `MULTICA_CODEX_PATH` 环境变量指定 codex 路径(它的 `config.json` 只支持 `server_url` / `app_url` / `workspace_id` 三个键)。直接 `multica daemon start` 拿不到这个变量,daemon 会**悄悄退回系统 codex、绕开号池**。

> ⚠️ **别用 `multica daemon restart` 代替 `poolctl daemon restart`。** 前者由已在运行的 daemon 自己拉起新进程,会继承旧环境,新设的变量传不进去。`poolctl daemon restart` 是 stop 再 start。
`poolctl daemon start/restart` 会将 `mcodex` 绝对路径持久化到
`~/.multica/config.json` 的 `backends.codex.binary_path`,同时传入
`MULTICA_CODEX_PATH` 以兼容旧版 Multica。持久化后,直接运行
`multica daemon start` 也会继续使用 `mcodex`。

> ⚠️ **旧版 Multica 仍请使用 `poolctl daemon restart`。** 旧版不读取
> `backends.codex.binary_path`,而 `multica daemon restart` 只会继承旧进程环境。
> 待 Multica 支持该持久化配置后,两种启动方式都会继续使用 `mcodex`。

---

Expand Down
16 changes: 9 additions & 7 deletions corp_codex_pool/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
summarize_by_key,
summarize_by_session,
)
from .multica import MulticaClient, MulticaError
from .multica import MulticaClient, MulticaError, persist_codex_binary_path

CONTEXT = {"help_option_names": ["-h", "--help"]}

Expand Down Expand Up @@ -681,11 +681,11 @@ def enroll(ctx, inherit, home, real_codex, restart_daemon):
click.echo(f" mcodex:{info['config']}")
click.echo(" 凭证:任务启动时自动申请,任务外无法使用")

env = _daemon_env(settings)
if not restart_daemon:
click.echo("\n下一步:poolctl daemon restart")
return

env = _daemon_env(settings)
subprocess.run(["multica", "daemon", "stop"], env=env, check=False)
result = subprocess.run(["multica", "daemon", "start"], env=env, check=False)
if result.returncode != 0:
Expand All @@ -697,11 +697,9 @@ def enroll(ctx, inherit, home, real_codex, restart_daemon):
def daemon():
"""带号池配置启停 multica daemon。

multica 只能通过 MULTICA_CODEX_PATH 环境变量指定 codex 可执行文件,
它的 config.json 不支持这个键。直接 `multica daemon start` 拿不到这个
变量,daemon 就会退回系统 codex、绕开号池。

这组命令在启动前把变量设好,避免依赖 shell profile 或人工记忆。
启动前同时持久化 backends.codex.binary_path 并设置
MULTICA_CODEX_PATH。前者保证之后直接 `multica daemon start`
仍使用 mcodex,后者兼容尚未支持持久化路径的旧版 Multica。
"""


Expand All @@ -712,6 +710,10 @@ def _daemon_env(settings) -> dict[str, str]:
found = _shutil.which("mcodex")
if not found:
_fail("PATH 里找不到 mcodex", "先 pip install -e .")
try:
persist_codex_binary_path(found)
except MulticaError as exc:
_fail(str(exc), "先运行 multica login 并检查 ~/.multica/config.json")
return {**os.environ, "MULTICA_CODEX_PATH": found}


Expand Down
34 changes: 34 additions & 0 deletions corp_codex_pool/codex_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@

# 行首的 [table] 或 [[array-of-table]] 头
_TABLE_RE = re.compile(r"^\s*\[\[?[^\[\]]", re.MULTILINE)
_TABLE_HEADER_RE = re.compile(
r"^\s*\[([^\[\]]+)\]\s*(?:#.*)?(?:\r?\n)?$"
)
_TOP_LEVEL_MODEL_PROVIDER_RE = re.compile(
r"^[ \t]*model_provider[ \t]*=[^\n]*(?:\n|$)",
re.MULTILINE,
)


class ConfigInjectionError(RuntimeError):
Expand Down Expand Up @@ -149,9 +156,36 @@ def _insert_position(text: str) -> int:
return match.start() if match else len(text)


def _strip_conflicting_entries(text: str, spec: ProviderSpec) -> str:
"""Remove entries owned by the managed provider block.

A per-task config is copied from the host before mcodex injects its block.
The host may already select another provider (for example the official GUI
integration) or contain an older unmanaged definition of this provider.
Keeping either entry would make the resulting TOML redefine a key/table.
"""
if spec.set_default:
pos = _insert_position(text)
head = _TOP_LEVEL_MODEL_PROVIDER_RE.sub("", text[:pos])
text = head + text[pos:]

provider_table = f"model_providers.{spec.provider_id}"
output: list[str] = []
skipping = False
for line in text.splitlines(keepends=True):
match = _TABLE_HEADER_RE.match(line)
if match:
table = match.group(1).strip()
skipping = table == provider_table or table.startswith(provider_table + ".")
if not skipping:
output.append(line)
return "".join(output)


def build_new_text(original: str, spec: ProviderSpec) -> str:
"""算出注入后的完整文件内容(纯函数,便于测试)。"""
body = strip_block(original)
body = _strip_conflicting_entries(body, spec)
block = render_block(spec)
pos = _insert_position(body)

Expand Down
55 changes: 55 additions & 0 deletions corp_codex_pool/multica.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from __future__ import annotations

import json
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
Expand All @@ -43,6 +45,59 @@ def load_local_config(path: Path | None = None) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))


def persist_codex_binary_path(
binary_path: str | Path,
config_path: Path | None = None,
) -> bool:
"""Persist the daemon's Codex executable without dropping other config.

Newer Multica daemons read ``backends.codex.binary_path`` directly. The
pool wrapper still exports ``MULTICA_CODEX_PATH`` for compatibility with
older daemons, but persisting the same path makes a later plain
``multica daemon start`` retain mcodex.
"""
binary = Path(binary_path).expanduser()
if not binary.is_absolute():
raise MulticaError(f"mcodex 路径必须是绝对路径:{binary}")

path = config_path or Path.home() / ".multica" / "config.json"
config = load_local_config(path)
backends = config.setdefault("backends", {})
if not isinstance(backends, dict):
raise MulticaError("multica 配置中 backends 必须是对象")
codex = backends.setdefault("codex", {})
if not isinstance(codex, dict):
raise MulticaError("multica 配置中 backends.codex 必须是对象")

normalized = str(binary.resolve(strict=False))
if codex.get("binary_path") == normalized:
return False
codex["binary_path"] = normalized

temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=".config-",
suffix=".json.tmp",
delete=False,
) as temp:
temp_path = Path(temp.name)
json.dump(config, temp, ensure_ascii=False, indent=2)
temp.write("\n")
temp.flush()
os.fsync(temp.fileno())
temp_path.chmod(0o600)
os.replace(temp_path, path)
except OSError as exc:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
raise MulticaError(f"写入 multica 配置失败:{exc}") from exc
return True


class MulticaClient:
def __init__(self, server_url: str, token: str, workspace_id: str, timeout: float = 30.0):
if not token:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "corp-codex-pool"
version = "0.3.0"
version = "0.3.1"
description = "Codex 号池与 multica 运行时的集成层:provider 注入、密钥下发、用量对账"
requires-python = ">=3.11"
dependencies = [
Expand Down
36 changes: 36 additions & 0 deletions tests/test_codex_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,42 @@ def test_strip_restores_original(self):
injected = build_new_text(REAL_CONFIG, spec())
assert tomllib.loads(strip_block(injected)) == tomllib.loads(REAL_CONFIG)

def test_existing_default_provider_is_replaced_without_duplicate_key(self):
src = '''\
model_provider = "chek"

[model_providers.chek]
name = "Official managed access"
base_url = "https://official.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
'''

data = tomllib.loads(build_new_text(src, spec()))

assert data["model_provider"] == "gw"
assert data["model_providers"]["chek"]["requires_openai_auth"] is True
assert data["model_providers"]["gw"]["base_url"] == "https://gw.example.com/v1"

def test_existing_unmanaged_same_provider_is_replaced(self):
src = '''\
model_provider = "gw"

[model_providers.gw]
name = "Old pool"
base_url = "https://old.example.com/v1"
wire_api = "responses"
env_key = "OLD_POOL_KEY"
'''

out = build_new_text(src, spec())
data = tomllib.loads(out)

assert out.count("model_provider =") == 1
assert out.count("[model_providers.gw]") == 1
assert data["model_providers"]["gw"]["base_url"] == "https://gw.example.com/v1"
assert data["model_providers"]["gw"]["env_key"] == "GW_API_KEY"


class TestValidation:
def test_wire_api_chat_rejected(self):
Expand Down
39 changes: 39 additions & 0 deletions tests/test_multica.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import json

import pytest

from corp_codex_pool.multica import MulticaError, persist_codex_binary_path


def test_persist_codex_binary_path_preserves_existing_config(tmp_path):
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"server_url": "https://multica.example.com",
"token": "secret-sentinel",
"future_field": {"keep": True},
"backends": {"openclaw": {"state_dir": "/var/lib/openclaw"}},
}
),
encoding="utf-8",
)

changed = persist_codex_binary_path("/opt/company/bin/mcodex", config_path)

assert changed is True
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert saved["backends"]["codex"]["binary_path"] == "/opt/company/bin/mcodex"
assert saved["backends"]["openclaw"]["state_dir"] == "/var/lib/openclaw"
assert saved["future_field"] == {"keep": True}
assert saved["token"] == "secret-sentinel"
assert config_path.stat().st_mode & 0o777 == 0o600
assert persist_codex_binary_path("/opt/company/bin/mcodex", config_path) is False


def test_persist_codex_binary_path_rejects_relative_path(tmp_path):
config_path = tmp_path / "config.json"
config_path.write_text("{}", encoding="utf-8")

with pytest.raises(MulticaError, match="绝对路径"):
persist_codex_binary_path("relative/mcodex", config_path)
Loading