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
38 changes: 34 additions & 4 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
# coding-proxy 配置模板
# 复制到 ./config.yaml(项目根目录)或 ~/.coding-proxy/config.yaml 并填入实际的 ZHIPU_API_KEY
# 复制到 ./config.yaml(项目根目录)或 ~/.coding-proxy/config.yaml 并填入实际密钥
# 优先级:./config.yaml > ~/.coding-proxy/config.yaml

server:
host: "127.0.0.1"
port: 8046

# === 后端层级(按优先级排列) ===

# Tier 0: Anthropic Claude Team Plan(最高优先级)
primary:
enabled: true
base_url: "https://api.anthropic.com"
timeout_ms: 300000

# Tier 1: GitHub Copilot Team Plan(中间优先级)
copilot:
enabled: false # 默认禁用,启用需配置 github_token
github_token: "${GITHUB_TOKEN}" # GitHub PAT(需 Copilot 权限)
token_url: "https://github.com/github-copilot/chat/token"
base_url: "https://api.individual.githubcopilot.com"
timeout_ms: 300000

# Tier 2: 智谱 GLM(终端兜底)
fallback:
enabled: true
base_url: "https://open.bigmodel.cn/api/anthropic"
api_key: "${ZHIPU_API_KEY}"
timeout_ms: 3000000

# === 弹性配置 ===

# Anthropic 熔断器
circuit_breaker:
failure_threshold: 3
recovery_timeout_seconds: 300
success_threshold: 2

# Copilot 熔断器
copilot_circuit_breaker:
failure_threshold: 3
recovery_timeout_seconds: 300
success_threshold: 2

failover:
status_codes: [429, 403, 503, 500]
error_types:
Expand All @@ -42,12 +63,21 @@ model_mapping:
target: "glm-4.5-air"
is_regex: true

# Anthropic 配额守卫
quota_guard:
enabled: true
token_budget: 45000000 # 5 小时 token 预算(根据订阅计划调整)
window_hours: 5.0 # 滑动窗口小时数
threshold_percent: 99.0 # 触发切换的百分比
probe_interval_seconds: 300 # 5 分钟探测一次
window_hours: 5.0
threshold_percent: 99.0
probe_interval_seconds: 300

# Copilot 配额守卫
copilot_quota_guard:
enabled: false # 默认禁用;启用后按 Premium Requests 配额管理
token_budget: 0
window_hours: 24.0
threshold_percent: 95.0
probe_interval_seconds: 300

database:
path: "~/.coding-proxy/usage.db"
Expand Down
29 changes: 3 additions & 26 deletions src/coding/proxy/backends/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
from typing import Any

from ..config.schema import AnthropicConfig, FailoverConfig
from .base import BaseBackend

_SKIP_HEADERS = {"host", "content-length", "transfer-encoding", "connection"}
from .base import PROXY_SKIP_HEADERS, BaseBackend


class AnthropicBackend(BaseBackend):
Expand All @@ -17,8 +15,7 @@ class AnthropicBackend(BaseBackend):
"""

def __init__(self, config: AnthropicConfig, failover_config: FailoverConfig) -> None:
super().__init__(config.base_url, config.timeout_ms)
self._failover_config = failover_config
super().__init__(config.base_url, config.timeout_ms, failover_config)

def get_name(self) -> str:
return "anthropic"
Expand All @@ -29,25 +26,5 @@ def _prepare_request(
headers: dict[str, str],
) -> tuple[dict[str, Any], dict[str, str]]:
"""透传请求体,过滤无关请求头."""
filtered = {k: v for k, v in headers.items() if k.lower() not in _SKIP_HEADERS}
filtered = {k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS}
return request_body, filtered

def should_trigger_failover(self, status_code: int, body: dict[str, Any] | None) -> bool:
"""判断是否应触发故障转移."""
if status_code not in self._failover_config.status_codes:
return False

if body and "error" in body:
error = body["error"]
error_type = error.get("type", "")
error_message = error.get("message", "").lower()

if error_type in self._failover_config.error_types:
return True

for pattern in self._failover_config.error_message_patterns:
if pattern.lower() in error_message:
return True

# 对于 429 和 503,即使无法解析 body 也触发故障转移
return status_code in (429, 503)
34 changes: 31 additions & 3 deletions src/coding/proxy/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@

import httpx

from ..config.schema import FailoverConfig

logger = logging.getLogger(__name__)

# 代理转发时应跳过的 hop-by-hop 请求头
PROXY_SKIP_HEADERS = {"host", "content-length", "transfer-encoding", "connection"}


@dataclass
class UsageInfo:
Expand Down Expand Up @@ -38,9 +43,15 @@ class BackendResponse:
class BaseBackend(ABC):
"""后端抽象基类,提供 HTTP 客户端管理和请求模板."""

def __init__(self, base_url: str, timeout_ms: int) -> None:
def __init__(
self,
base_url: str,
timeout_ms: int,
failover_config: FailoverConfig | None = None,
) -> None:
self._base_url = base_url
self._timeout_ms = timeout_ms
self._failover_config = failover_config
self._client: httpx.AsyncClient | None = None

def _get_client(self) -> httpx.AsyncClient:
Expand All @@ -63,9 +74,26 @@ def _prepare_request(
) -> tuple[dict[str, Any], dict[str, str]]:
"""准备请求体和请求头,由子类实现差异化逻辑."""

@abstractmethod
def should_trigger_failover(self, status_code: int, body: dict[str, Any] | None) -> bool:
"""判断响应是否应触发故障转移."""
"""基于 FailoverConfig 的通用故障转移判断.

无 failover_config 时返回 False(终端后端默认行为).
"""
if self._failover_config is None:
return False
if status_code not in self._failover_config.status_codes:
return False
if body and "error" in body:
error = body["error"]
error_type = error.get("type", "")
error_message = error.get("message", "").lower()
if error_type in self._failover_config.error_types:
return True
for pattern in self._failover_config.error_message_patterns:
if pattern.lower() in error_message:
return True
# 429/503 即使无法解析 body 也触发故障转移
return status_code in (429, 503)

async def send_message_stream(
self,
Expand Down
203 changes: 203 additions & 0 deletions src/coding/proxy/backends/copilot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""GitHub Copilot 后端 — 内置 token 交换与 Anthropic 兼容转发."""

from __future__ import annotations

import asyncio
import logging
import time
from typing import Any

import httpx

from ..config.schema import CopilotConfig, FailoverConfig
from .base import PROXY_SKIP_HEADERS, BaseBackend

logger = logging.getLogger(__name__)


class CopilotTokenManager:
"""管理 GitHub Copilot token 的交换与自动刷新.

流程: GitHub PAT → POST token_url → Copilot access_token (~30 分钟有效期)
"""

# 提前刷新的余量(秒)
_REFRESH_MARGIN = 60

def __init__(self, github_token: str, token_url: str) -> None:
self._github_token = github_token
self._token_url = token_url
self._access_token: str | None = None
self._expires_at: float = 0.0
self._lock = asyncio.Lock()
self._client: httpx.AsyncClient | None = None

def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return self._client

async def get_token(self) -> str:
"""获取有效的 Copilot access_token(带缓存和自动刷新)."""
if self._access_token and time.monotonic() < self._expires_at:
return self._access_token

async with self._lock:
# Double-check after acquiring lock
if self._access_token and time.monotonic() < self._expires_at:
return self._access_token
await self._exchange()
assert self._access_token is not None
return self._access_token

async def _exchange(self) -> None:
"""通过 GitHub PAT 交换 Copilot token."""
client = self._get_client()
response = await client.post(
self._token_url,
headers={
"authorization": f"token {self._github_token}",
"accept": "application/json",
"content-type": "application/json",
},
)
response.raise_for_status()
data = response.json()
self._access_token = data["access_token"]
expires_in = data.get("expires_in", 1800)
self._expires_at = time.monotonic() + expires_in - self._REFRESH_MARGIN
logger.info("Copilot token exchanged, expires_in=%ds", expires_in)

def invalidate(self) -> None:
"""标记当前 token 失效(触发下次请求时被动刷新)."""
self._expires_at = 0.0

async def close(self) -> None:
if self._client and not self._client.is_closed:
await self._client.aclose()


class CopilotBackend(BaseBackend):
"""GitHub Copilot API 后端.

通过内置 token 交换访问 GitHub Copilot 的 Anthropic 兼容端点.
透传请求体(无模型映射),Claude 模型名原生支持.
"""

def __init__(self, config: CopilotConfig, failover_config: FailoverConfig) -> None:
super().__init__(config.base_url, config.timeout_ms, failover_config)
self._token_manager = CopilotTokenManager(config.github_token, config.token_url)

def get_name(self) -> str:
return "copilot"

def _prepare_request(
self,
request_body: dict[str, Any],
headers: dict[str, str],
) -> tuple[dict[str, Any], dict[str, str]]:
"""透传请求体,注入 Copilot token 认证头.

注: token 注入在 _prepare_request_async 中完成.
此方法为同步占位,实际使用 send_message / send_message_stream 的异步覆写.
"""
filtered = {k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS}
return request_body, filtered

async def _prepare_request_async(
self,
request_body: dict[str, Any],
headers: dict[str, str],
) -> tuple[dict[str, Any], dict[str, str]]:
"""异步准备请求:获取 Copilot token 并注入认证头."""
body, filtered = self._prepare_request(request_body, headers)
token = await self._token_manager.get_token()
filtered["authorization"] = f"Bearer {token}"
return body, filtered

async def send_message(
self,
request_body: dict[str, Any],
headers: dict[str, str],
) -> Any:
"""发送非流式消息请求(覆写以使用异步 token 获取)."""
body, prepared_headers = await self._prepare_request_async(request_body, headers)
client = self._get_client()

response = await client.post(
"/v1/messages",
json=body,
headers=prepared_headers,
)

# 401/403 时标记 token 失效以触发被动刷新
if response.status_code in (401, 403):
self._token_manager.invalidate()

raw_content = response.content
resp_body = response.json() if response.content else None

if response.status_code >= 400:
from .base import BackendResponse

return BackendResponse(
status_code=response.status_code,
raw_body=raw_content,
error_type=resp_body.get("error", {}).get("type") if resp_body else None,
error_message=resp_body.get("error", {}).get("message") if resp_body else None,
)

usage = resp_body.get("usage", {}) if resp_body else {}
from .base import BackendResponse, UsageInfo

return BackendResponse(
status_code=response.status_code,
raw_body=raw_content,
usage=UsageInfo(
input_tokens=usage.get("input_tokens", 0),
output_tokens=usage.get("output_tokens", 0),
cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
cache_read_tokens=usage.get("cache_read_input_tokens", 0),
request_id=resp_body.get("id", "") if resp_body else "",
),
)

async def send_message_stream(
self,
request_body: dict[str, Any],
headers: dict[str, str],
):
"""发送流式消息请求(覆写以使用异步 token 获取)."""
body, prepared_headers = await self._prepare_request_async(request_body, headers)
client = self._get_client()

async with client.stream(
"POST",
"/v1/messages",
json=body,
headers=prepared_headers,
) as response:
if response.status_code in (401, 403):
self._token_manager.invalidate()
if response.status_code >= 400:
error_body = await response.aread()
logger.warning(
"%s stream error: status=%d body=%s",
self.get_name(), response.status_code, error_body[:500],
)
raise httpx.HTTPStatusError(
f"{self.get_name()} API error: {response.status_code}",
request=response.request,
response=httpx.Response(
response.status_code,
content=error_body,
headers=response.headers,
request=response.request,
),
)
async for chunk in response.aiter_bytes():
yield chunk

async def close(self) -> None:
await self._token_manager.close()
await super().close()
Loading