Skip to content

Commit cc94dc7

Browse files
Fzkujiclaude
andcommitted
feat(claude-code): pin a fixed Meridian profile, decoupled from terminal login (P0)
OpenProgram's claude-code provider followed whatever account the terminal `claude auth login` last logged in (Meridian with no profile uses the keychain's current session), so switching the Claude Code account dragged OpenProgram along. Now OpenProgram pins a fixed Meridian account by injecting `x-meridian-profile` (Meridian's highest-priority profile selector) on every claude-code request, read from config.providers.claude-code.meridian_profile (or env CLAUDE_MAX_PROXY_PROFILE / MERIDIAN_PROFILE) per request — live (no restart) and independent of the keychain login. Injection lives in openai_completions.stream_simple (the single chokepoint every claude-code request passes through) via an inject_profile_header() helper in the claude-code module. An adversarial review caught that the first cut (in providers/stream.py's wrapper) was bypassed by memory/llm_bridge.py calling the raw api-provider directly, which would have leaked summarization traffic onto the terminal account; moving it down covers every path. Gating on provider=="claude-code" inside openai_completions also means a future claude-code-cli (different wire) model can't get a meaningless header. Tests (10): inject_profile_header behaviour, meridian_profile() precedence + non-string guard, and an integration test that the header reaches the openai client default_headers. Verified end-to-end over real HTTP. P1 (WebUI profile picker) and P2 (auto-install Meridian) still to come — see docs/design/claude-code-meridian-profile.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1adf1e0 commit cc94dc7

5 files changed

Lines changed: 268 additions & 21 deletions

File tree

docs/design/claude-code-meridian-profile.md

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,24 @@ header `x-meridian-profile: <name>` 是最高优先级**,会盖过钥匙串当
6262

6363
### 注入点
6464

65-
`openprogram/providers/stream.py``stream_simple()` —— 所有 provider 的
66-
统一入口,已经在这里做 per-provider 处理(注入 api_key)。在调
67-
`provider.stream_simple(...)` 之前,若 `model.provider == "claude-code"`
68-
且解析出 profile,就把 `x-meridian-profile` 合并进 `opts.headers`
69-
70-
选这里而不是 `openai_completions`(claude-code 走的 wire)的原因:
71-
72-
- `openai_completions` 是被很多 provider 共用的通用层,不该塞 claude-code
73-
专属逻辑;
74-
- `stream.py` 已经按 `model.provider` 做事,语义一致;
75-
- 注入到 `opts.headers` 后,`openai_completions` 现有的
76-
`extra_headers = opts.headers or {}` 直接发出去,**openai_completions
77-
无需改动**
65+
`openprogram/providers/openai_completions/openai_completions.py`
66+
`stream_simple()` —— **claude-code 所有请求的唯一必经点**。claude-code 的
67+
model `api="openai-completions"`,所以不论请求经由 `providers/stream.py`
68+
的统一 wrapper、还是被某些调用方(如 `memory/llm_bridge.py` 直接调
69+
`api_provider.stream_simple`)绕过 wrapper,最终都落到这个函数。
70+
71+
> 设计评审纠正:最初把注入放在 `providers/stream.py` 的 wrapper 里,但对抗
72+
> 审查发现 `memory/llm_bridge.py` 直接调 raw api-provider、绕过 wrapper,
73+
> 于是 memory summarization 在默认模型是 claude-code 时仍不带 header、泄漏
74+
> 到终端登录账号 —— 正是本设计要消除的耦合。改放 openai_completions 这个
75+
> chokepoint 后覆盖全部路径。
76+
77+
注入逻辑封装在 claude-code 模块的
78+
`_claude_max_proxy_registry.inject_profile_header(model, headers)`(不把
79+
claude-code 专属逻辑塞进通用层),`openai_completions` 仅在
80+
`model.provider == "claude-code"` 时惰性 import 并调用它。由于只有
81+
`api="openai-completions"` 的 model 才会进 openai_completions,将来即便重新
82+
启用 `claude-code-cli`(另一种 wire)的同名 provider 模型,也不会被误注入。
7883

7984
### 立即生效
8085

@@ -88,10 +93,22 @@ worker**。`opts` 用 `model_copy` 注入,不改原对象,无副作用。
8893
尊重调用方的(per-call 比全局配置更具体)。即:
8994
`{"x-meridian-profile": profile, **(opts.headers or {})}`
9095

91-
### `_meridian_profile()` 落点
96+
### `meridian_profile()` / `inject_profile_header()` 落点
9297

9398
放在 `openprogram/providers/anthropic/_claude_max_proxy_registry.py`
94-
(claude-code 相关模块已经在这),`stream.py` 惰性 import 以避免循环。
99+
(claude-code 相关模块已经在这),`openai_completions` 惰性 import 以避免
100+
循环。`meridian_profile()` 对非字符串配置值做 `str()` 兜底,免得手改
101+
config.json 写错类型时在 `.strip()` 抛错被静默吞掉。
102+
103+
### 已知限制
104+
105+
- **不校验 profile 是否真实存在**:Meridian 没有 JSON profiles API,这一层
106+
无法在发请求前确认 `meridian_profile` 名字有效。名字错了由 Meridian 决定
107+
行为(报错→正常 API error,或静默回落到它的 default = 可能用错账号)。
108+
P1 的 WebUI picker 用 `meridian profile list` 把可选值约束到已知 profile,
109+
从源头堵住这个问题。
110+
- **每请求读一次 config.json**(~0.3ms,被代理+模型网络往返淹没)。为支持
111+
"WebUI 改了立即生效"而不缓存;若日后成为热点,可按 mtime 记忆化。
95112

96113
## P1(后续)WebUI 配置入口
97114

@@ -109,9 +126,12 @@ worker**。`opts` 用 `model_copy` 注入,不改原对象,无副作用。
109126

110127
## 验证
111128

112-
- 单元:`stream_simple``model.provider=="claude-code"` 且配了 profile
113-
时,`provider.stream_simple` 收到的 `opts.headers`
114-
`x-meridian-profile`;未配时不含;非 claude-code provider 永不受影响。
115-
- 端到端:配 profile 后从 OpenProgram 实跑一次 claude-code,确认 Meridian
116-
路由到该 profile 的账号;`claude auth status`(终端)仍是另一个账号、
117-
不受影响。
129+
- 单元(`tests/unit/test_claude_code_meridian_profile.py`,10 passed):
130+
`inject_profile_header` 在 claude-code + pin 时加 header,未配 / 其他
131+
provider / caller 自带 header 时的行为;`meridian_profile()`
132+
config > env > None 优先级与非字符串兜底;集成测试确认 claude-code 经
133+
`openai_completions.stream_simple` 时 header 真的进了 openai client 的
134+
`default_headers`
135+
- 端到端(P1/P2 配好 profile 后):从 OpenProgram 实跑一次 claude-code,确认
136+
Meridian 路由到该 profile 的账号;`claude auth status`(终端)仍是另一个
137+
账号、不受影响。

openprogram/providers/anthropic/_claude_max_proxy_registry.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,75 @@ def _proxy_base_url() -> str:
3636
return base
3737

3838

39+
def meridian_profile() -> str | None:
40+
"""The Meridian profile OpenProgram's claude-code traffic is pinned to.
41+
42+
Meridian (the local Claude proxy) can hold several Claude accounts as
43+
named profiles and routes each request to the one named by an
44+
``x-meridian-profile`` header — its highest-priority profile selector,
45+
overriding both the keychain's current ``claude auth login`` session
46+
and Meridian's own active/default profile. Pinning one here is what
47+
decouples OpenProgram's Claude account from the terminal Claude Code
48+
login (see docs/design/claude-code-meridian-profile.md).
49+
50+
Resolution order:
51+
1. ``config.providers.claude-code.meridian_profile`` — set manually
52+
in config.json today; a WebUI control to write it is P1 (planned).
53+
Read per request so a change takes effect live.
54+
2. env ``CLAUDE_MAX_PROXY_PROFILE`` (alias ``MERIDIAN_PROFILE``).
55+
3. ``None`` — no header; Meridian falls back to its active/default
56+
profile or the keychain login (unchanged legacy behaviour).
57+
58+
No validation that the named profile actually exists in Meridian —
59+
that needs a Meridian profiles API we don't have. A bad name makes
60+
Meridian either error (surfaced as a normal API error) or silently use
61+
its default; the WebUI picker (P1) will constrain the value to known
62+
profiles so this can't happen via the UI.
63+
"""
64+
try:
65+
from openprogram.setup import _read_config
66+
67+
pcfg = (_read_config().get("providers") or {}).get("claude-code") or {}
68+
# str() guards a hand-edited non-string value (e.g. a bare number)
69+
# from raising inside .strip() and being swallowed below.
70+
val = str(pcfg.get("meridian_profile") or "").strip()
71+
if val:
72+
return val
73+
except Exception:
74+
# config unreadable (fresh install, race) — fall through to env.
75+
pass
76+
val = (
77+
os.environ.get("CLAUDE_MAX_PROXY_PROFILE")
78+
or os.environ.get("MERIDIAN_PROFILE")
79+
or ""
80+
).strip()
81+
return val or None
82+
83+
84+
def inject_profile_header(model, headers: dict | None) -> dict:
85+
"""Return ``headers`` plus the pinned ``x-meridian-profile`` for
86+
claude-code, if one is configured and the caller didn't set it.
87+
88+
Called from ``openai_completions.stream_simple`` — the single layer
89+
every claude-code request passes through (the ``providers/stream.py``
90+
wrapper is bypassed by some callers, e.g. memory summarization). The
91+
gate on ``provider == "claude-code"`` plus the fact that only
92+
``api == "openai-completions"`` models reach openai_completions means a
93+
CLI-api claude-code model (different wire) never gets a meaningless
94+
header. A caller-supplied ``x-meridian-profile`` wins (it's more
95+
specific than the global config binding). Always returns a fresh dict.
96+
"""
97+
out = dict(headers or {})
98+
if (
99+
getattr(model, "provider", None) == "claude-code"
100+
and "x-meridian-profile" not in out
101+
):
102+
profile = meridian_profile()
103+
if profile:
104+
out["x-meridian-profile"] = profile
105+
return out
106+
107+
39108
# Only three model ids the ``claude-max-api`` proxy actually
40109
# recognises (verified against ``GET /v1/models`` on v1.0.0). Anything
41110
# else the proxy silently downgrades to ``claude-haiku-4``, so we

openprogram/providers/openai_completions/openai_completions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ async def stream_simple(
198198

199199
base_url = model.base_url if model.base_url != "https://api.openai.com/v1" else None
200200
extra_headers = opts.headers or {}
201+
# claude-code: pin a fixed Meridian account (profile) so OpenProgram's
202+
# Claude subscription is decoupled from whatever the terminal
203+
# `claude auth login` last logged in. This is the single chokepoint
204+
# every claude-code request passes through (the providers/stream.py
205+
# wrapper is bypassed by some callers, e.g. memory summarization), so
206+
# the injection lives here, not there. No-op for every other provider.
207+
# See docs/design/claude-code-meridian-profile.md.
208+
if model.provider == "claude-code":
209+
from ..anthropic._claude_max_proxy_registry import inject_profile_header
210+
extra_headers = inject_profile_header(model, extra_headers)
201211

202212
# Match other HTTP providers' stream retry budget (default 3) so
203213
# transient 429/5xx/connect failures are absorbed without

openprogram/providers/stream.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ async def stream_simple(
6161
if not opts.api_key:
6262
opts = opts.model_copy(update={"api_key": get_env_api_key(model.provider)})
6363

64+
# NOTE: the claude-code Meridian-profile header (x-meridian-profile) is
65+
# injected one layer down, in openai_completions.stream_simple — that's
66+
# the single chokepoint EVERY claude-code request passes through. This
67+
# wrapper is bypassed by some callers (e.g. memory/llm_bridge.py calls
68+
# the raw api-provider directly), so injecting here would miss them.
69+
# See docs/design/claude-code-meridian-profile.md.
70+
6471
provider = get_api_provider(model.api)
6572
if provider is None:
6673
raise ValueError(f"No stream function registered for API: {model.api!r}")
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""claude-code pins a fixed Meridian account (profile) via the
2+
``x-meridian-profile`` header, so OpenProgram's Claude subscription is
3+
decoupled from whatever the terminal ``claude auth login`` last logged
4+
in. See docs/design/claude-code-meridian-profile.md.
5+
6+
The injection lives in ``openai_completions.stream_simple`` — the single
7+
chokepoint every claude-code request passes through (the
8+
``providers/stream.py`` wrapper is bypassed by some callers, e.g. memory
9+
summarization). Tests cover the pure resolver/injector plus an
10+
integration check that the header actually reaches the openai client.
11+
12+
Note: several provider modules are shadowed as package attributes, so we
13+
grab the real module objects from ``sys.modules`` and patch by object.
14+
"""
15+
from __future__ import annotations
16+
17+
import asyncio
18+
import sys
19+
from types import SimpleNamespace
20+
21+
import openprogram.providers.openai_completions.openai_completions # noqa: F401
22+
import openprogram.providers.anthropic._claude_max_proxy_registry # noqa: F401
23+
import openprogram.setup # noqa: F401
24+
from openprogram.providers.types import Context, Model, SimpleStreamOptions
25+
26+
_REG = sys.modules["openprogram.providers.anthropic._claude_max_proxy_registry"]
27+
_OC = sys.modules["openprogram.providers.openai_completions.openai_completions"]
28+
_SETUP = sys.modules["openprogram.setup"]
29+
30+
31+
def _model(provider: str, api: str = "openai-completions") -> Model:
32+
return Model(
33+
id="claude-sonnet-4", name="x", api=api, provider=provider,
34+
base_url="http://localhost:3456/v1",
35+
)
36+
37+
38+
# ── inject_profile_header (pure) ──────────────────────────────────────────
39+
40+
def test_injects_for_claude_code_when_pinned(monkeypatch):
41+
monkeypatch.setattr(_REG, "meridian_profile", lambda: "experiment")
42+
out = _REG.inject_profile_header(_model("claude-code"), {"a": "1"})
43+
assert out["x-meridian-profile"] == "experiment"
44+
assert out["a"] == "1" # existing headers preserved
45+
46+
47+
def test_no_header_when_no_profile(monkeypatch):
48+
monkeypatch.setattr(_REG, "meridian_profile", lambda: None)
49+
out = _REG.inject_profile_header(_model("claude-code"), None)
50+
assert "x-meridian-profile" not in out
51+
52+
53+
def test_other_provider_never_injected(monkeypatch):
54+
# Even with a profile configured, a non-claude-code model must not get it.
55+
monkeypatch.setattr(_REG, "meridian_profile", lambda: "experiment")
56+
out = _REG.inject_profile_header(_model("openai"), None)
57+
assert "x-meridian-profile" not in out
58+
59+
60+
def test_caller_header_wins(monkeypatch):
61+
monkeypatch.setattr(_REG, "meridian_profile", lambda: "experiment")
62+
out = _REG.inject_profile_header(
63+
_model("claude-code"), {"x-meridian-profile": "adhoc"},
64+
)
65+
assert out["x-meridian-profile"] == "adhoc"
66+
67+
68+
def test_returns_fresh_dict(monkeypatch):
69+
monkeypatch.setattr(_REG, "meridian_profile", lambda: "experiment")
70+
src = {"a": "1"}
71+
out = _REG.inject_profile_header(_model("claude-code"), src)
72+
assert out is not src and "x-meridian-profile" not in src
73+
74+
75+
# ── meridian_profile() resolution ─────────────────────────────────────────
76+
77+
def _cfg(profile):
78+
return {"providers": {"claude-code": {"meridian_profile": profile}}}
79+
80+
81+
def test_resolve_config_wins_over_env(monkeypatch):
82+
monkeypatch.setattr(_SETUP, "_read_config", lambda: _cfg("acctA"))
83+
monkeypatch.setenv("CLAUDE_MAX_PROXY_PROFILE", "envP")
84+
assert _REG.meridian_profile() == "acctA"
85+
86+
87+
def test_resolve_env_fallback(monkeypatch):
88+
monkeypatch.setattr(_SETUP, "_read_config", lambda: {"providers": {}})
89+
monkeypatch.delenv("MERIDIAN_PROFILE", raising=False)
90+
monkeypatch.setenv("CLAUDE_MAX_PROXY_PROFILE", "envP")
91+
assert _REG.meridian_profile() == "envP"
92+
93+
94+
def test_resolve_none_when_unset(monkeypatch):
95+
monkeypatch.setattr(_SETUP, "_read_config", lambda: {})
96+
monkeypatch.delenv("CLAUDE_MAX_PROXY_PROFILE", raising=False)
97+
monkeypatch.delenv("MERIDIAN_PROFILE", raising=False)
98+
assert _REG.meridian_profile() is None
99+
100+
101+
def test_resolve_non_string_config_does_not_crash(monkeypatch):
102+
# A hand-edited non-string value must not raise (and get swallowed).
103+
monkeypatch.setattr(_SETUP, "_read_config", lambda: _cfg(123))
104+
monkeypatch.delenv("CLAUDE_MAX_PROXY_PROFILE", raising=False)
105+
monkeypatch.delenv("MERIDIAN_PROFILE", raising=False)
106+
assert _REG.meridian_profile() == "123" # coerced, not crashed
107+
108+
109+
# ── integration: openai_completions actually sends the header ─────────────
110+
111+
def test_openai_completions_sends_header_for_claude_code(monkeypatch):
112+
captured: dict = {}
113+
114+
class _FakeClient:
115+
def __init__(self, **kwargs):
116+
captured["default_headers"] = kwargs.get("default_headers")
117+
118+
async def _create(**_):
119+
raise RuntimeError("stop-after-client-built")
120+
121+
self.chat = SimpleNamespace(
122+
completions=SimpleNamespace(create=_create),
123+
)
124+
125+
monkeypatch.setattr(_OC._openai, "AsyncOpenAI", _FakeClient)
126+
monkeypatch.setattr(_REG, "meridian_profile", lambda: "experiment")
127+
128+
async def go() -> None:
129+
try:
130+
async for _ in _OC.stream_simple(
131+
_model("claude-code"), Context(messages=[]),
132+
SimpleStreamOptions(api_key="x"),
133+
):
134+
pass
135+
except Exception:
136+
pass # create aborts on purpose; client (+headers) already built
137+
138+
asyncio.run(go())
139+
assert (captured.get("default_headers") or {}).get(
140+
"x-meridian-profile"
141+
) == "experiment"

0 commit comments

Comments
 (0)