Skip to content
Open
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
68 changes: 50 additions & 18 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pathlib import Path
from typing import cast

from ucode import gateway_proxy
from ucode.agent_updates import available_npm_package_update
from ucode.anthropic_model_discovery_proxy import (
start_proxy as start_anthropic_model_discovery_proxy,
Expand All @@ -33,10 +34,6 @@
build_tool_base_url,
get_databricks_token,
)
from ucode.gateway_proxy import (
AI_GATEWAY_TOKEN_HEADER,
AUTHORIZATION_HEADER,
)
from ucode.launcher import exec_or_spawn
from ucode.managed_files import OS, current_os, write_managed_file
from ucode.smart_routing import v2 as smart_routing_v2
Expand All @@ -58,6 +55,8 @@
CLAUDE_USER_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "settings.json"
CLAUDE_BACKUP_PATH = APP_DIR / "claude-ucode-settings.backup.json"
WEB_SEARCH_MCP_STATE_KEY = "claude_web_search_mcp"
MINIMUM_CLAUDE_VERSION = (2, 1, 248)
MINIMUM_CLAUDE_VERSION_TEXT = "2.1.248"

SPEC: ToolSpec = {
"binary": "claude",
Expand Down Expand Up @@ -98,6 +97,49 @@ def is_update_available() -> tuple[str, str] | None:
return available_npm_package_update(SPEC["package"])


def _parse_version(value: str) -> tuple[int, int, int] | None:
match = re.search(r"(\d+)\.(\d+)\.(\d+)", value)
if not match:
return None
major, minor, patch = match.groups()
return int(major), int(minor), int(patch)


def _installed_version_status() -> tuple[str, bool] | None:
version = agent_version(SPEC["binary"])
parsed = _parse_version(version)
if parsed is None:
return None
return version, parsed < MINIMUM_CLAUDE_VERSION


def minimum_version_error() -> str | None:
status = _installed_version_status()
if status is None:
return None
version, is_too_old = status
if not is_too_old:
return None
return (
f"Claude Code {version} is too old for gateway model discovery. "
f"Claude Code must be updated to {MINIMUM_CLAUDE_VERSION_TEXT} or newer; "
f"run `npm install -g {SPEC['package']}` or `ucode configure`."
)


def required_update_message() -> str | None:
status = _installed_version_status()
if status is None:
return None
version, is_too_old = status
if not is_too_old:
return None
return (
f"Claude Code {version} is older than required {MINIMUM_CLAUDE_VERSION_TEXT}; "
"updating Claude Code is required for gateway model discovery."
)


def _resolve_web_search_model(state: dict) -> str | None:
"""Pick the model the web_search MCP server should call. Prefers an
explicit override in state, otherwise the first endpoint discovered as
Expand Down Expand Up @@ -1122,11 +1164,11 @@ def _launch_relayed(state: dict, binary: str, tool_args: list[str]) -> None:
if not isinstance(port, int):
raise RuntimeError("Relayed proxy port was not configured; re-run `ucode claude`.")

server, cache, client = start_anthropic_model_discovery_proxy(
server, cache, client = gateway_proxy.start_proxy(
workspace,
state.get("profile"),
port,
token_header=AI_GATEWAY_TOKEN_HEADER,
token_header=gateway_proxy.AI_GATEWAY_TOKEN_HEADER,
force_refresh_near_expiry=False,
)
# start_proxy falls back to an OS-assigned port when the cached one is taken
Expand Down Expand Up @@ -1155,18 +1197,9 @@ def _launch_relayed(state: dict, binary: str, tool_args: list[str]) -> None:
def _launch_claude_with_gateway_proxy(
state: dict, binary: str, tool_args: list[str], *, smart_routing: bool
) -> None:
"""Launch Claude through a refreshing gateway proxy."""
"""Launch Claude through the gateway model-alias proxy."""
workspace = state["workspace"]
server, cache, client = start_anthropic_model_discovery_proxy(
workspace,
state.get("profile"),
0,
token_header=AUTHORIZATION_HEADER,
force_refresh_near_expiry=True,
)
token = cache.token
os.environ["OAUTH_TOKEN"] = token
os.environ["ANTHROPIC_AUTH_TOKEN"] = token
server, client = start_anthropic_model_discovery_proxy(workspace, 0)
os.environ["ANTHROPIC_BASE_URL"] = f"http://{LOOPBACK_HOST}:{server.server_address[1]}"
os.environ["CLAUDE_CODE_USE_GATEWAY"] = "1"

Expand Down Expand Up @@ -1201,7 +1234,6 @@ def compose_gateway_settings(args: list[str]) -> tuple[dict, list[str]]:
proc.send_signal(signal.SIGINT)
returncode = proc.wait()
finally:
cache.stop()
server.shutdown()
client.close()
raise SystemExit(returncode)
Expand Down
65 changes: 16 additions & 49 deletions src/ucode/anthropic_model_discovery_proxy.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""Loopback proxy for Claude gateway model discovery.

The proxy refreshes the Databricks credential, streams inference responses
verbatim, and rewrites model discovery responses when needed.
The proxy forwards Claude Code's apiKeyHelper credential, streams inference
responses verbatim, and rewrites model discovery responses when needed.

Security invariants (mirroring `databricks.py` token handling):
- Binds 127.0.0.1 only; never exposed off-host.
- Never logs header values or bodies. The Databricks token lives in memory
and is refreshed off the request path.
- Never logs header values or bodies.
"""

from __future__ import annotations
Expand All @@ -26,13 +25,9 @@
from ucode.constants import LOOPBACK_HOST
from ucode.databricks import _http_get_retry_delay
from ucode.gateway_proxy import (
AI_GATEWAY_TOKEN_HEADER,
HOP_BY_HOP_HEADERS,
UPSTREAM_TIMEOUT,
TokenCache,
forwarded_request_headers,
log_proxy_diagnostic,
log_token_refresh_failure,
)

# Claude Code abandons model discovery after roughly three seconds. One retry
Expand All @@ -42,9 +37,7 @@

class _ProxyHandler(BaseHTTPRequestHandler):
# Set by the server factory.
cache: TokenCache
client: httpx.Client
token_header = AI_GATEWAY_TOKEN_HEADER

def log_message(self, format: str, *args: object) -> None:
return
Expand Down Expand Up @@ -87,7 +80,11 @@ def _retry_model_discovery(
delay_ms=round(delay * 1000),
)
time.sleep(delay)
headers = forwarded_request_headers(self, self.cache.token, self.token_header)
headers = {
key: value
for key, value in self.headers.items()
if key.lower() not in HOP_BY_HOP_HEADERS
}
with self.client.stream(self.command, url, headers=headers, content=body) as resp:
log_proxy_diagnostic(
"model_discovery_upstream_headers",
Expand Down Expand Up @@ -118,8 +115,11 @@ def _handle(self) -> None:
path=self.path.split("?", 1)[0],
)
try:
# First attempt with the current token.
headers = forwarded_request_headers(self, self.cache.token, self.token_header)
headers = {
key: value
for key, value in self.headers.items()
if key.lower() not in HOP_BY_HOP_HEADERS
}
with self.client.stream(self.command, url, headers=headers, content=body) as resp:
log_proxy_diagnostic(
"model_discovery_upstream_headers",
Expand All @@ -139,27 +139,6 @@ def _handle(self) -> None:
retry_after,
)
return
if resp.status_code not in (401, 403):
self._relay_response(resp, diagnostic_id=diagnostic_id, started=started)
return
# Auth rejected. Drain the (small) error body so the pooled
# connection can be reused, then fall through to one retry.
resp.read()
# Force-refresh the Databricks token and retry once.
try:
self.cache.refresh()
except RuntimeError as exc:
# Still retry with the existing token after reporting the failure.
log_token_refresh_failure(exc)
headers = forwarded_request_headers(self, self.cache.token, self.token_header)
with self.client.stream(self.command, url, headers=headers, content=body) as resp:
log_proxy_diagnostic(
"model_discovery_upstream_headers",
request_id=diagnostic_id,
attempt=2,
status=resp.status_code,
elapsed_ms=round((time.monotonic() - started) * 1000),
)
self._relay_response(resp, diagnostic_id=diagnostic_id, started=started)
except (BrokenPipeError, ConnectionResetError):
# Client closed before/while we relayed headers — routine on cancel.
Expand Down Expand Up @@ -366,28 +345,18 @@ def _response_chunks(self, resp: httpx.Response) -> tuple[Iterable[bytes], froze

def start_proxy(
workspace: str,
profile: str | None,
port: int,
token_header: str,
force_refresh_near_expiry: bool,
) -> tuple[ThreadingHTTPServer, TokenCache, httpx.Client]:
"""Start the Anthropic model discovery proxy and token refresher."""
) -> tuple[ThreadingHTTPServer, httpx.Client]:
"""Start the Anthropic model discovery proxy."""
upstream_base = f"{workspace.rstrip('/')}/ai-gateway/anthropic/"
cache = TokenCache(
workspace,
profile,
force_refresh_near_expiry=force_refresh_near_expiry,
)
client = httpx.Client(base_url=upstream_base, timeout=UPSTREAM_TIMEOUT, follow_redirects=False)
handler = cast(
type[BaseHTTPRequestHandler],
type(
"BoundProxyHandler",
(_AnthropicModelDiscoveryHandler,),
{
"cache": cache,
"client": client,
"token_header": token_header,
"anthropic_model_aliases": _AnthropicModelAliases(),
},
),
Expand All @@ -397,6 +366,4 @@ def start_proxy(
except OSError:
server = ThreadingHTTPServer((LOOPBACK_HOST, 0), handler)

refresher = threading.Thread(target=cache.run_refresher, daemon=True)
refresher.start()
return server, cache, client
return server, client
Loading
Loading