mirror of
https://github.com/katanemo/plano.git
synced 2026-06-08 14:55:14 +02:00
feat(claude-cli): add local Claude Code CLI provider bridge
Spawn the local `claude` binary as a subprocess and expose it as an
Anthropic Messages-compatible provider. Hosted in brightstaff
(`CLAUDE_CLI_LISTEN_ADDR`), with session reuse, idle TTL, and watchdog.
User-facing surface is `model_providers: [{ model: claude-cli/* }]` —
the Python CLI auto-fills name/provider_interface/base_url/access_key
and the launcher (native + supervisord) enables the bridge listener
only when at least one claude-cli provider is present.
This commit is contained in:
parent
b71a555f19
commit
9fdfeb7cbf
26 changed files with 2847 additions and 2 deletions
|
|
@ -39,11 +39,64 @@ CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex"
|
|||
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
|
||||
CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown"
|
||||
|
||||
# Local-only bridge that runs Claude Code CLI as a subprocess. Hosted by
|
||||
# brightstaff on this loopback address; the Python CLI auto-fills the matching
|
||||
# provider fields below and tells the launcher to enable the bridge.
|
||||
CLAUDE_CLI_DEFAULT_BASE_URL = "http://127.0.0.1:14001"
|
||||
CLAUDE_CLI_DEFAULT_LISTEN_ADDR = "127.0.0.1:14001"
|
||||
CLAUDE_CLI_DEFAULT_NAME = "claude-cli/*"
|
||||
CLAUDE_CLI_DEFAULT_ACCESS_KEY_PLACEHOLDER = "claude-cli-local"
|
||||
|
||||
SUPPORTED_PROVIDERS = (
|
||||
SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL
|
||||
)
|
||||
|
||||
|
||||
def _is_claude_cli_provider(model_provider):
|
||||
"""Return True iff this provider entry refers to the local claude-cli
|
||||
bridge. Triggered by any of `model`, `name`, or `provider_interface`
|
||||
matching the `claude-cli/...` namespace.
|
||||
"""
|
||||
model = (model_provider.get("model") or "").strip()
|
||||
name = (model_provider.get("name") or "").strip()
|
||||
interface = (model_provider.get("provider_interface") or "").strip()
|
||||
return (
|
||||
model.startswith("claude-cli/")
|
||||
or name.startswith("claude-cli/")
|
||||
or interface == "claude-cli"
|
||||
)
|
||||
|
||||
|
||||
def _apply_claude_cli_autofill(model_provider):
|
||||
"""Fill in implicit fields for `claude-cli/*` provider entries so the
|
||||
user only has to write `model: claude-cli/*` (or any `claude-cli/...`)
|
||||
and everything else is wired automatically: a localhost cluster pointing
|
||||
at the brightstaff bridge, the `claude-cli` provider_interface, and a
|
||||
placeholder access key so downstream validation does not reject the entry.
|
||||
|
||||
Returns True iff this entry was treated as a claude-cli provider (so the
|
||||
caller can flip the launcher's `needs_claude_cli_runtime` flag).
|
||||
"""
|
||||
if not _is_claude_cli_provider(model_provider):
|
||||
return False
|
||||
|
||||
if not model_provider.get("name"):
|
||||
model_provider["name"] = model_provider.get("model") or CLAUDE_CLI_DEFAULT_NAME
|
||||
if not model_provider.get("provider_interface"):
|
||||
model_provider["provider_interface"] = "claude-cli"
|
||||
if not model_provider.get("base_url"):
|
||||
model_provider["base_url"] = CLAUDE_CLI_DEFAULT_BASE_URL
|
||||
# Keep passthrough_auth users alone; the bridge ignores the access key
|
||||
# anyway (it uses the host's `claude auth login` keychain), so a
|
||||
# placeholder is fine for everyone else.
|
||||
if not model_provider.get("access_key") and not model_provider.get(
|
||||
"passthrough_auth"
|
||||
):
|
||||
model_provider["access_key"] = CLAUDE_CLI_DEFAULT_ACCESS_KEY_PLACEHOLDER
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_endpoint_and_port(endpoint, protocol):
|
||||
endpoint_tokens = endpoint.split(":")
|
||||
if len(endpoint_tokens) > 1:
|
||||
|
|
@ -329,6 +382,12 @@ def validate_and_render_schema():
|
|||
name = listener.get("name", None)
|
||||
|
||||
for model_provider in listener.get("model_providers", []):
|
||||
# Auto-fill the implicit fields for `claude-cli/*` providers
|
||||
# before the rest of the loop runs validation. This makes
|
||||
# `model_providers: [{model: claude-cli/*}]` a fully-formed
|
||||
# entry by the time we reach the wildcard checks below.
|
||||
_apply_claude_cli_autofill(model_provider)
|
||||
|
||||
if model_provider.get("usage", None):
|
||||
llms_with_usage.append(model_provider["name"])
|
||||
if model_provider.get("name") in model_provider_name_set:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,61 @@ from planoai.utils import find_repo_root, getLogger
|
|||
|
||||
log = getLogger(__name__)
|
||||
|
||||
CLAUDE_CLI_DEFAULT_LISTEN_ADDR = "127.0.0.1:14001"
|
||||
# Env vars the user can set to customize the bridge. We always honor a
|
||||
# pre-set CLAUDE_CLI_LISTEN_ADDR (so power users can move the listener)
|
||||
# but otherwise inject the default whenever a claude-cli provider is
|
||||
# detected in the rendered config.
|
||||
CLAUDE_CLI_PASSTHROUGH_ENV = (
|
||||
"CLAUDE_CLI_LISTEN_ADDR",
|
||||
"CLAUDE_CLI_BIN",
|
||||
"CLAUDE_CLI_PERMISSION_MODE",
|
||||
"CLAUDE_CLI_SESSION_TTL_SECS",
|
||||
"CLAUDE_CLI_WATCHDOG_SECS",
|
||||
"CLAUDE_CLI_MAX_SESSIONS",
|
||||
)
|
||||
|
||||
|
||||
def _needs_claude_cli_runtime(plano_config_rendered_path) -> bool:
|
||||
"""True iff the rendered config has at least one model_provider whose
|
||||
`provider_interface` is `claude-cli`. The Python config_generator
|
||||
auto-fills this field when it sees a `claude-cli/*` model entry, so the
|
||||
detection is one-step regardless of how the user wrote the original
|
||||
provider line.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
try:
|
||||
with open(plano_config_rendered_path, "r") as f:
|
||||
rendered = yaml.safe_load(f) or {}
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
for provider in rendered.get("model_providers") or []:
|
||||
if (provider or {}).get("provider_interface") == "claude-cli":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _apply_claude_cli_env(brightstaff_env, plano_config_rendered_path):
|
||||
"""If the rendered config opts into the claude-cli bridge, ensure
|
||||
`CLAUDE_CLI_LISTEN_ADDR` is set in the brightstaff process environment so
|
||||
the bridge listener actually starts. Honors any pre-set values from the
|
||||
caller's env (so users can override the listen address, binary path, or
|
||||
permission mode without editing this file).
|
||||
"""
|
||||
if not _needs_claude_cli_runtime(plano_config_rendered_path):
|
||||
return False
|
||||
if not brightstaff_env.get("CLAUDE_CLI_LISTEN_ADDR"):
|
||||
brightstaff_env["CLAUDE_CLI_LISTEN_ADDR"] = CLAUDE_CLI_DEFAULT_LISTEN_ADDR
|
||||
for key in CLAUDE_CLI_PASSTHROUGH_ENV:
|
||||
if key in os.environ and key not in brightstaff_env:
|
||||
brightstaff_env[key] = os.environ[key]
|
||||
log.info(
|
||||
"claude-cli bridge enabled: brightstaff will listen on %s",
|
||||
brightstaff_env["CLAUDE_CLI_LISTEN_ADDR"],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _find_config_dir():
|
||||
"""Locate the directory containing plano_config_schema.yaml and envoy.template.yaml.
|
||||
|
|
@ -197,6 +252,11 @@ def start_native(
|
|||
for key, value in env.items():
|
||||
brightstaff_env[key] = value
|
||||
|
||||
# Enable the claude-cli bridge if the rendered config asks for it. Done
|
||||
# after `env.items()` is merged so user-set CLAUDE_CLI_* env vars take
|
||||
# precedence over the auto-injected defaults.
|
||||
_apply_claude_cli_env(brightstaff_env, plano_config_rendered_path)
|
||||
|
||||
brightstaff_pid = _daemon_exec(
|
||||
[brightstaff_path],
|
||||
brightstaff_env,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ import pytest
|
|||
import yaml
|
||||
from unittest import mock
|
||||
from planoai.config_generator import (
|
||||
validate_and_render_schema,
|
||||
CLAUDE_CLI_DEFAULT_BASE_URL,
|
||||
_apply_claude_cli_autofill,
|
||||
_is_claude_cli_provider,
|
||||
migrate_inline_routing_preferences,
|
||||
validate_and_render_schema,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -738,3 +741,64 @@ model_providers:
|
|||
migrate_inline_routing_preferences(config_yaml)
|
||||
|
||||
assert config_yaml["version"] == "v0.5.0"
|
||||
|
||||
|
||||
def test_claude_cli_autofill_wildcard_provider():
|
||||
provider = {"model": "claude-cli/*"}
|
||||
assert _is_claude_cli_provider(provider) is True
|
||||
assert _apply_claude_cli_autofill(provider) is True
|
||||
assert provider["name"] == "claude-cli/*"
|
||||
assert provider["provider_interface"] == "claude-cli"
|
||||
assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
|
||||
assert provider["access_key"] == "claude-cli-local"
|
||||
# `model` itself must not be rewritten — the wildcard expansion happens
|
||||
# downstream and we want to preserve the user's intent.
|
||||
assert provider["model"] == "claude-cli/*"
|
||||
|
||||
|
||||
def test_claude_cli_autofill_specific_model():
|
||||
provider = {"model": "claude-cli/sonnet", "default": True}
|
||||
assert _apply_claude_cli_autofill(provider) is True
|
||||
assert provider["name"] == "claude-cli/sonnet"
|
||||
assert provider["provider_interface"] == "claude-cli"
|
||||
assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
|
||||
# Existing fields like `default` survive.
|
||||
assert provider["default"] is True
|
||||
|
||||
|
||||
def test_claude_cli_autofill_does_not_override_user_fields():
|
||||
provider = {
|
||||
"model": "claude-cli/*",
|
||||
"name": "custom-name",
|
||||
"base_url": "http://192.0.2.10:9000",
|
||||
"access_key": "do-not-touch",
|
||||
}
|
||||
assert _apply_claude_cli_autofill(provider) is True
|
||||
assert provider["name"] == "custom-name"
|
||||
assert provider["base_url"] == "http://192.0.2.10:9000"
|
||||
assert provider["access_key"] == "do-not-touch"
|
||||
# provider_interface still gets injected because it was missing.
|
||||
assert provider["provider_interface"] == "claude-cli"
|
||||
|
||||
|
||||
def test_claude_cli_autofill_skips_non_matching_providers():
|
||||
provider = {"model": "openai/gpt-4o"}
|
||||
assert _is_claude_cli_provider(provider) is False
|
||||
assert _apply_claude_cli_autofill(provider) is False
|
||||
assert "provider_interface" not in provider
|
||||
|
||||
|
||||
def test_claude_cli_autofill_passthrough_auth_skips_access_key():
|
||||
provider = {"model": "claude-cli/*", "passthrough_auth": True}
|
||||
assert _apply_claude_cli_autofill(provider) is True
|
||||
# Honor passthrough_auth: do not inject a placeholder access_key.
|
||||
assert "access_key" not in provider
|
||||
assert provider["passthrough_auth"] is True
|
||||
|
||||
|
||||
def test_claude_cli_autofill_detects_via_provider_interface_only():
|
||||
provider = {"model": "sonnet", "provider_interface": "claude-cli"}
|
||||
assert _is_claude_cli_provider(provider) is True
|
||||
assert _apply_claude_cli_autofill(provider) is True
|
||||
assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
|
||||
assert provider["name"] == "sonnet"
|
||||
|
|
|
|||
112
cli/test/test_native_runner_claude_cli.py
Normal file
112
cli/test/test_native_runner_claude_cli.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Unit tests for the claude-cli env wiring in native_runner.py."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
from planoai.native_runner import (
|
||||
CLAUDE_CLI_DEFAULT_LISTEN_ADDR,
|
||||
_apply_claude_cli_env,
|
||||
_needs_claude_cli_runtime,
|
||||
)
|
||||
|
||||
|
||||
def _write(path, body):
|
||||
path.write_text(textwrap.dedent(body).lstrip())
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_needs_claude_cli_runtime_detects_provider(tmp_path):
|
||||
rendered = _write(
|
||||
tmp_path / "rendered.yaml",
|
||||
"""
|
||||
version: v0.4.0
|
||||
listeners: []
|
||||
model_providers:
|
||||
- name: claude-cli/*
|
||||
model: '*'
|
||||
provider_interface: claude-cli
|
||||
base_url: http://127.0.0.1:14001
|
||||
""",
|
||||
)
|
||||
assert _needs_claude_cli_runtime(rendered) is True
|
||||
|
||||
|
||||
def test_needs_claude_cli_runtime_skips_other_providers(tmp_path):
|
||||
rendered = _write(
|
||||
tmp_path / "rendered.yaml",
|
||||
"""
|
||||
version: v0.4.0
|
||||
model_providers:
|
||||
- name: openai/gpt-4o
|
||||
model: gpt-4o
|
||||
provider_interface: openai
|
||||
""",
|
||||
)
|
||||
assert _needs_claude_cli_runtime(rendered) is False
|
||||
|
||||
|
||||
def test_needs_claude_cli_runtime_handles_missing_file(tmp_path):
|
||||
assert _needs_claude_cli_runtime(str(tmp_path / "does-not-exist.yaml")) is False
|
||||
|
||||
|
||||
def test_apply_claude_cli_env_injects_default_addr(tmp_path, monkeypatch):
|
||||
rendered = _write(
|
||||
tmp_path / "rendered.yaml",
|
||||
"""
|
||||
model_providers:
|
||||
- provider_interface: claude-cli
|
||||
model: '*'
|
||||
""",
|
||||
)
|
||||
monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CLI_BIN", raising=False)
|
||||
env = {}
|
||||
assert _apply_claude_cli_env(env, rendered) is True
|
||||
assert env["CLAUDE_CLI_LISTEN_ADDR"] == CLAUDE_CLI_DEFAULT_LISTEN_ADDR
|
||||
|
||||
|
||||
def test_apply_claude_cli_env_honors_user_override(tmp_path, monkeypatch):
|
||||
rendered = _write(
|
||||
tmp_path / "rendered.yaml",
|
||||
"""
|
||||
model_providers:
|
||||
- provider_interface: claude-cli
|
||||
model: '*'
|
||||
""",
|
||||
)
|
||||
monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
|
||||
env = {"CLAUDE_CLI_LISTEN_ADDR": "127.0.0.1:25000"}
|
||||
assert _apply_claude_cli_env(env, rendered) is True
|
||||
assert env["CLAUDE_CLI_LISTEN_ADDR"] == "127.0.0.1:25000"
|
||||
|
||||
|
||||
def test_apply_claude_cli_env_passes_through_user_env(tmp_path, monkeypatch):
|
||||
rendered = _write(
|
||||
tmp_path / "rendered.yaml",
|
||||
"""
|
||||
model_providers:
|
||||
- provider_interface: claude-cli
|
||||
model: '*'
|
||||
""",
|
||||
)
|
||||
monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CLI_BIN", "/usr/local/bin/claude-test")
|
||||
monkeypatch.setenv("CLAUDE_CLI_PERMISSION_MODE", "default")
|
||||
env = {}
|
||||
assert _apply_claude_cli_env(env, rendered) is True
|
||||
assert env["CLAUDE_CLI_BIN"] == "/usr/local/bin/claude-test"
|
||||
assert env["CLAUDE_CLI_PERMISSION_MODE"] == "default"
|
||||
|
||||
|
||||
def test_apply_claude_cli_env_noop_for_other_configs(tmp_path):
|
||||
rendered = _write(
|
||||
tmp_path / "rendered.yaml",
|
||||
"""
|
||||
model_providers:
|
||||
- provider_interface: openai
|
||||
model: gpt-4o
|
||||
""",
|
||||
)
|
||||
env = {}
|
||||
assert _apply_claude_cli_env(env, rendered) is False
|
||||
assert "CLAUDE_CLI_LISTEN_ADDR" not in env
|
||||
Loading…
Add table
Add a link
Reference in a new issue