coding-proxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coding/__init__.py +0 -0
- coding/proxy/__init__.py +3 -0
- coding/proxy/__main__.py +5 -0
- coding/proxy/auth/__init__.py +13 -0
- coding/proxy/auth/providers/__init__.py +6 -0
- coding/proxy/auth/providers/base.py +35 -0
- coding/proxy/auth/providers/github.py +133 -0
- coding/proxy/auth/providers/google.py +237 -0
- coding/proxy/auth/runtime.py +122 -0
- coding/proxy/auth/store.py +74 -0
- coding/proxy/cli/__init__.py +151 -0
- coding/proxy/cli/auth_commands.py +224 -0
- coding/proxy/compat/__init__.py +30 -0
- coding/proxy/compat/canonical.py +193 -0
- coding/proxy/compat/session_store.py +137 -0
- coding/proxy/config/__init__.py +6 -0
- coding/proxy/config/auth_schema.py +24 -0
- coding/proxy/config/loader.py +139 -0
- coding/proxy/config/resiliency.py +46 -0
- coding/proxy/config/routing.py +279 -0
- coding/proxy/config/schema.py +280 -0
- coding/proxy/config/server.py +23 -0
- coding/proxy/config/vendors.py +53 -0
- coding/proxy/convert/__init__.py +14 -0
- coding/proxy/convert/anthropic_to_gemini.py +352 -0
- coding/proxy/convert/anthropic_to_openai.py +352 -0
- coding/proxy/convert/gemini_sse_adapter.py +169 -0
- coding/proxy/convert/gemini_to_anthropic.py +98 -0
- coding/proxy/convert/openai_to_anthropic.py +88 -0
- coding/proxy/logging/__init__.py +49 -0
- coding/proxy/logging/db.py +308 -0
- coding/proxy/logging/stats.py +129 -0
- coding/proxy/model/__init__.py +93 -0
- coding/proxy/model/auth.py +32 -0
- coding/proxy/model/compat.py +153 -0
- coding/proxy/model/constants.py +21 -0
- coding/proxy/model/pricing.py +70 -0
- coding/proxy/model/token.py +64 -0
- coding/proxy/model/vendor.py +218 -0
- coding/proxy/pricing.py +100 -0
- coding/proxy/routing/__init__.py +47 -0
- coding/proxy/routing/circuit_breaker.py +152 -0
- coding/proxy/routing/error_classifier.py +67 -0
- coding/proxy/routing/executor.py +453 -0
- coding/proxy/routing/model_mapper.py +90 -0
- coding/proxy/routing/quota_guard.py +169 -0
- coding/proxy/routing/rate_limit.py +159 -0
- coding/proxy/routing/retry.py +82 -0
- coding/proxy/routing/router.py +84 -0
- coding/proxy/routing/session_manager.py +62 -0
- coding/proxy/routing/tier.py +171 -0
- coding/proxy/routing/usage_parser.py +193 -0
- coding/proxy/routing/usage_recorder.py +131 -0
- coding/proxy/server/__init__.py +1 -0
- coding/proxy/server/app.py +142 -0
- coding/proxy/server/factory.py +175 -0
- coding/proxy/server/request_normalizer.py +139 -0
- coding/proxy/server/responses.py +74 -0
- coding/proxy/server/routes.py +264 -0
- coding/proxy/streaming/__init__.py +1 -0
- coding/proxy/streaming/anthropic_compat.py +484 -0
- coding/proxy/vendors/__init__.py +29 -0
- coding/proxy/vendors/anthropic.py +44 -0
- coding/proxy/vendors/antigravity.py +328 -0
- coding/proxy/vendors/base.py +353 -0
- coding/proxy/vendors/copilot.py +702 -0
- coding/proxy/vendors/copilot_models.py +438 -0
- coding/proxy/vendors/copilot_token_manager.py +167 -0
- coding/proxy/vendors/copilot_urls.py +16 -0
- coding/proxy/vendors/mixins.py +71 -0
- coding/proxy/vendors/token_manager.py +128 -0
- coding/proxy/vendors/zhipu.py +243 -0
- coding_proxy-0.1.0.dist-info/METADATA +184 -0
- coding_proxy-0.1.0.dist-info/RECORD +77 -0
- coding_proxy-0.1.0.dist-info/WHEEL +4 -0
- coding_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- coding_proxy-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""CLI 入口 — Typer 命令行工具.
|
|
2
|
+
|
|
3
|
+
Auth 子命令已正交提取至 :mod:`.auth_commands`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..config.schema import ProxyConfig
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from ..config.loader import load_config
|
|
21
|
+
from ..logging.db import TokenLogger
|
|
22
|
+
from ..logging.stats import show_usage
|
|
23
|
+
from .auth_commands import app as auth_app, auto_login_if_needed as _auto_login_if_needed
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(name="coding-proxy", help="Claude Code 多供应商智能代理服务")
|
|
26
|
+
console = Console()
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# 注册 Auth 子应用
|
|
30
|
+
app.add_typer(auth_app, name="auth")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_token_store(cfg_path: Path | None = None):
|
|
34
|
+
"""按配置解析 Token Store 路径并完成加载."""
|
|
35
|
+
from ..auth.store import TokenStoreManager
|
|
36
|
+
|
|
37
|
+
cfg = load_config(cfg_path)
|
|
38
|
+
store = TokenStoreManager(
|
|
39
|
+
store_path=Path(cfg.auth.token_store_path) if cfg.auth.token_store_path else None,
|
|
40
|
+
)
|
|
41
|
+
store.load()
|
|
42
|
+
logger.debug("OAuth token store loaded from config path: %s", cfg.auth.token_store_path)
|
|
43
|
+
return cfg, store
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── 主命令 ─────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def start(
|
|
51
|
+
config: Optional[str] = typer.Option(None, "--config", "-c", help="配置文件路径"),
|
|
52
|
+
port: Optional[int] = typer.Option(None, "--port", "-p", help="监听端口"),
|
|
53
|
+
host: Optional[str] = typer.Option(None, "--host", "-h", help="监听地址"),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""启动代理服务."""
|
|
56
|
+
import uvicorn
|
|
57
|
+
|
|
58
|
+
from ..server.app import create_app
|
|
59
|
+
|
|
60
|
+
cfg_path = _resolve_config_path(config)
|
|
61
|
+
cfg = load_config(cfg_path)
|
|
62
|
+
|
|
63
|
+
if port:
|
|
64
|
+
cfg.server.port = port
|
|
65
|
+
if host:
|
|
66
|
+
cfg.server.host = host
|
|
67
|
+
|
|
68
|
+
# 自动登录检查
|
|
69
|
+
asyncio.run(_auto_login_if_needed(cfg_path))
|
|
70
|
+
|
|
71
|
+
from ..logging import build_log_config
|
|
72
|
+
|
|
73
|
+
fastapi_app = create_app(cfg)
|
|
74
|
+
uvicorn.run(
|
|
75
|
+
fastapi_app,
|
|
76
|
+
host=cfg.server.host,
|
|
77
|
+
port=cfg.server.port,
|
|
78
|
+
log_config=build_log_config(cfg.logging.level),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def status(
|
|
84
|
+
port: int = typer.Option(8046, "--port", "-p", help="代理服务端口"),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""查看代理状态和当前活跃供应商."""
|
|
87
|
+
import httpx
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
resp = httpx.get(f"http://127.0.0.1:{port}/api/status", timeout=5)
|
|
91
|
+
data = resp.json()
|
|
92
|
+
for tier_info in data.get("tiers", []):
|
|
93
|
+
name = tier_info.get("name", "unknown")
|
|
94
|
+
console.print(f"\n[bold green]{name}[/bold green]")
|
|
95
|
+
cb = tier_info.get("circuit_breaker")
|
|
96
|
+
if cb:
|
|
97
|
+
console.print(f" [cyan]熔断器:[/] {cb.get('state', 'unknown')} 失败={cb.get('failure_count', 0)}")
|
|
98
|
+
qg = tier_info.get("quota_guard")
|
|
99
|
+
if qg:
|
|
100
|
+
console.print(f" [cyan]配额:[/] {qg.get('state', 'unknown')} {qg.get('usage_percent', 0)}% ({qg.get('window_usage_tokens', 0)}/{qg.get('budget_tokens', 0)})")
|
|
101
|
+
except httpx.ConnectError:
|
|
102
|
+
console.print("[red]代理服务未运行[/red]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def usage(
|
|
107
|
+
days: int = typer.Option(7, "--days", "-d", help="统计天数"),
|
|
108
|
+
vendor: Optional[str] = typer.Option(None, "--vendor", "-v", help="过滤供应商"),
|
|
109
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="过滤请求模型"),
|
|
110
|
+
db_path: Optional[str] = typer.Option(None, "--db", help="数据库路径"),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""查看 Token 使用统计."""
|
|
113
|
+
cfg = load_config(Path(db_path) if db_path else None)
|
|
114
|
+
token_logger = TokenLogger(cfg.db_path)
|
|
115
|
+
asyncio.run(_run_usage(token_logger, days, vendor, model, cfg))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _run_usage(token_logger: TokenLogger, days: int, vendor: str | None,
|
|
119
|
+
model: str | None, cfg: "ProxyConfig") -> None:
|
|
120
|
+
from ..pricing import PricingTable
|
|
121
|
+
await token_logger.init()
|
|
122
|
+
pricing_table = PricingTable(cfg.pricing)
|
|
123
|
+
await show_usage(token_logger, days, vendor, model, pricing_table)
|
|
124
|
+
await token_logger.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def reset(
|
|
129
|
+
port: int = typer.Option(8046, "--port", "-p", help="代理服务端口"),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""重置所有层级的熔断器和配额守卫(恢复使用最高优先级供应商)."""
|
|
132
|
+
import httpx
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
resp = httpx.post(f"http://127.0.0.1:{port}/api/reset", timeout=5)
|
|
136
|
+
if resp.status_code == 200:
|
|
137
|
+
console.print("[green]所有层级的熔断器和配额守卫已重置[/green]")
|
|
138
|
+
else:
|
|
139
|
+
console.print(f"[red]重置失败: {resp.status_code}[/red]")
|
|
140
|
+
except httpx.ConnectError:
|
|
141
|
+
console.print("[red]代理服务未运行[/red]")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_config_path(config: str | Path | None = None) -> Path | None:
|
|
145
|
+
"""标准化配置路径输入."""
|
|
146
|
+
if config is None:
|
|
147
|
+
return None
|
|
148
|
+
return config if isinstance(config, Path) else Path(config)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["app", "_build_token_store"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""CLI Auth 子命令 — OAuth 登录、状态、重认证与登出."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from ..config.loader import load_config
|
|
15
|
+
from ..auth.store import TokenStoreManager
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="auth", help="管理 OAuth 登录凭证")
|
|
18
|
+
console = Console()
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_token_store(cfg_path: Path | None = None):
|
|
23
|
+
"""按配置解析 Token Store 路径并完成加载."""
|
|
24
|
+
cfg = load_config(cfg_path)
|
|
25
|
+
store = TokenStoreManager(
|
|
26
|
+
store_path=Path(cfg.auth.token_store_path) if cfg.auth.token_store_path else None,
|
|
27
|
+
)
|
|
28
|
+
store.load()
|
|
29
|
+
logger.debug("OAuth token store loaded from config path: %s", cfg.auth.token_store_path)
|
|
30
|
+
return cfg, store
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Auth 子命令 ─────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("login")
|
|
37
|
+
def auth_login(
|
|
38
|
+
provider: Optional[str] = typer.Option(None, "--provider", "-p", help="指定 provider (github/google)"),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""执行 OAuth 浏览器登录."""
|
|
41
|
+
asyncio.run(_run_auth_login(provider))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _run_auth_login(provider: str | None) -> None:
|
|
45
|
+
from ..auth.providers.github import GitHubDeviceFlowProvider
|
|
46
|
+
from ..auth.providers.google import GoogleOAuthProvider
|
|
47
|
+
|
|
48
|
+
cfg, store = _build_token_store()
|
|
49
|
+
|
|
50
|
+
providers = []
|
|
51
|
+
if provider == "github":
|
|
52
|
+
providers = [("github", GitHubDeviceFlowProvider())]
|
|
53
|
+
elif provider == "google":
|
|
54
|
+
providers = [("google", GoogleOAuthProvider(
|
|
55
|
+
client_id=cfg.auth.google_client_id,
|
|
56
|
+
client_secret=cfg.auth.google_client_secret,
|
|
57
|
+
))]
|
|
58
|
+
elif provider is None:
|
|
59
|
+
providers = [
|
|
60
|
+
("github", GitHubDeviceFlowProvider()),
|
|
61
|
+
("google", GoogleOAuthProvider(
|
|
62
|
+
client_id=cfg.auth.google_client_id,
|
|
63
|
+
client_secret=cfg.auth.google_client_secret,
|
|
64
|
+
)),
|
|
65
|
+
]
|
|
66
|
+
else:
|
|
67
|
+
console.print(f"[red]未知 provider: {provider}[/red]")
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
|
|
70
|
+
for name, prov in providers:
|
|
71
|
+
try:
|
|
72
|
+
console.print(f"\n[bold cyan]登录 {name}...[/bold cyan]")
|
|
73
|
+
tokens = await prov.login()
|
|
74
|
+
store.set(name, tokens)
|
|
75
|
+
console.print(f"[green]{name} 登录成功[/green]")
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
console.print(f"[red]{name} 登录失败: {exc}[/red]")
|
|
78
|
+
finally:
|
|
79
|
+
await prov.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("status")
|
|
83
|
+
def auth_status() -> None:
|
|
84
|
+
"""查看已登录的 OAuth 凭证状态."""
|
|
85
|
+
_, store = _build_token_store()
|
|
86
|
+
|
|
87
|
+
providers = store.list_providers()
|
|
88
|
+
if not providers:
|
|
89
|
+
console.print("[yellow]尚未登录任何 provider[/yellow]")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
for name in providers:
|
|
93
|
+
tokens = store.get(name)
|
|
94
|
+
expired = tokens.is_expired
|
|
95
|
+
status_text = "[red]已过期[/red]" if expired else "[green]有效[/green]"
|
|
96
|
+
has_refresh = "有 refresh_token" if tokens.refresh_token else "无 refresh_token"
|
|
97
|
+
console.print(f" {name}: {status_text} {has_refresh}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("reauth")
|
|
101
|
+
def auth_reauth(
|
|
102
|
+
provider: str = typer.Argument(..., help="provider 名称 (github/google)"),
|
|
103
|
+
port: int = typer.Option(8046, "--port", "-p", help="代理服务端口"),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""触发运行中代理的 OAuth 重认证."""
|
|
106
|
+
import httpx as _httpx
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
resp = _httpx.post(f"http://127.0.0.1:{port}/api/reauth/{provider}", timeout=5)
|
|
110
|
+
if resp.status_code == 202:
|
|
111
|
+
console.print(f"[green]{provider} 重认证已触发,请在浏览器中完成登录[/green]")
|
|
112
|
+
elif resp.status_code == 404:
|
|
113
|
+
console.print(f"[red]重认证不可用(代理未启用对应后端)[/red]")
|
|
114
|
+
else:
|
|
115
|
+
console.print(f"[red]触发失败: {resp.status_code} {resp.text}[/red]")
|
|
116
|
+
except _httpx.ConnectError:
|
|
117
|
+
console.print("[red]代理服务未运行[/red]")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("logout")
|
|
121
|
+
def auth_logout(
|
|
122
|
+
provider: Optional[str] = typer.Option(None, "--provider", "-p", help="指定 provider(不指定则全部登出)"),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""清除已存储的 OAuth 凭证."""
|
|
125
|
+
_, store = _build_token_store()
|
|
126
|
+
|
|
127
|
+
if provider:
|
|
128
|
+
store.remove(provider)
|
|
129
|
+
console.print(f"[green]已登出 {provider}[/green]")
|
|
130
|
+
else:
|
|
131
|
+
for name in store.list_providers():
|
|
132
|
+
store.remove(name)
|
|
133
|
+
console.print("[green]已登出所有 provider[/green]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── 自动登录辅助 ─────────────────────────────────────────────
|
|
137
|
+
async def auto_login_if_needed(cfg_path: Path | None) -> None:
|
|
138
|
+
"""检查各 Provider 是否缺少凭证,自动触发浏览器登录.
|
|
139
|
+
|
|
140
|
+
仅对已启用、且未在 config 中显式提供凭证的 Tier 做检查。
|
|
141
|
+
对 Google/Antigravity,若本地存在 refresh_token 且 access_token 过期,
|
|
142
|
+
优先执行静默刷新,避免每次启动都重新走浏览器 OAuth。
|
|
143
|
+
|
|
144
|
+
三阶段检查:
|
|
145
|
+
1. needs_login() — 快速本地判断(无凭证或已过期且无 refresh_token)
|
|
146
|
+
2. refresh() — Google access_token 过期且存在 refresh_token 时静默刷新
|
|
147
|
+
3. validate() — 网络验证已有凭证是否仍有效(仅在有凭证且未刷新时触发)
|
|
148
|
+
"""
|
|
149
|
+
from ..auth.providers.github import GitHubDeviceFlowProvider
|
|
150
|
+
from ..auth.providers.google import GoogleOAuthProvider
|
|
151
|
+
|
|
152
|
+
cfg, store = _build_token_store(cfg_path)
|
|
153
|
+
|
|
154
|
+
async def _resolve_needs_login(provider, tokens) -> bool:
|
|
155
|
+
result = provider.needs_login(tokens)
|
|
156
|
+
if inspect.isawaitable(result):
|
|
157
|
+
return bool(await result)
|
|
158
|
+
return bool(result)
|
|
159
|
+
|
|
160
|
+
# --- GitHub / Copilot ---
|
|
161
|
+
if cfg.copilot.enabled and not cfg.copilot.github_token:
|
|
162
|
+
tokens = store.get("github")
|
|
163
|
+
prov = GitHubDeviceFlowProvider()
|
|
164
|
+
needs = await _resolve_needs_login(prov, tokens)
|
|
165
|
+
if not needs and tokens.has_credentials:
|
|
166
|
+
# 有凭证但可能过期/吊销 → 网络验证
|
|
167
|
+
try:
|
|
168
|
+
if not await prov.validate(tokens):
|
|
169
|
+
needs = True
|
|
170
|
+
except Exception:
|
|
171
|
+
pass # 网络失败不阻塞启动
|
|
172
|
+
if needs:
|
|
173
|
+
console.print("[bold cyan]Copilot 层缺少有效凭证,启动 GitHub OAuth 登录...[/bold cyan]")
|
|
174
|
+
try:
|
|
175
|
+
tokens = await prov.login()
|
|
176
|
+
store.set("github", tokens)
|
|
177
|
+
console.print("[green]GitHub 登录成功[/green]")
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
console.print(f"[red]GitHub 登录失败: {exc}[/red]")
|
|
180
|
+
finally:
|
|
181
|
+
await prov.close()
|
|
182
|
+
else:
|
|
183
|
+
await prov.close()
|
|
184
|
+
|
|
185
|
+
# --- Google / Antigravity ---
|
|
186
|
+
if cfg.antigravity.enabled and not cfg.antigravity.refresh_token:
|
|
187
|
+
tokens = store.get("google")
|
|
188
|
+
prov = GoogleOAuthProvider(
|
|
189
|
+
client_id=cfg.auth.google_client_id,
|
|
190
|
+
client_secret=cfg.auth.google_client_secret,
|
|
191
|
+
)
|
|
192
|
+
needs = await _resolve_needs_login(prov, tokens)
|
|
193
|
+
try:
|
|
194
|
+
if not needs and tokens.is_expired and tokens.refresh_token:
|
|
195
|
+
logger.info("Google access_token 已过期,尝试使用 refresh_token 静默刷新")
|
|
196
|
+
try:
|
|
197
|
+
tokens = await prov.refresh(tokens)
|
|
198
|
+
store.set("google", tokens)
|
|
199
|
+
logger.info("Google refresh_token 静默刷新成功")
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
logger.warning("Google refresh_token 静默刷新失败,回退交互登录: %s", exc)
|
|
202
|
+
console.print("[bold cyan]Antigravity 凭证刷新失败,启动 Google OAuth 登录...[/bold cyan]")
|
|
203
|
+
tokens = await prov.login()
|
|
204
|
+
store.set("google", tokens)
|
|
205
|
+
console.print("[green]Google 登录成功[/green]")
|
|
206
|
+
elif not needs and tokens.has_credentials:
|
|
207
|
+
try:
|
|
208
|
+
if not await prov.validate(tokens):
|
|
209
|
+
needs = True
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
if needs:
|
|
214
|
+
console.print("[bold cyan]Antigravity 层缺少有效凭证,启动 Google OAuth 登录...[/bold cyan]")
|
|
215
|
+
tokens = await prov.login()
|
|
216
|
+
store.set("google", tokens)
|
|
217
|
+
console.print("[green]Google 登录成功[/green]")
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
console.print(f"[red]Google 登录失败: {exc}[/red]")
|
|
220
|
+
finally:
|
|
221
|
+
await prov.close()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = ["app", "auto_login_if_needed"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Claude / Anthropic 语义兼容层."""
|
|
2
|
+
|
|
3
|
+
from .canonical import (
|
|
4
|
+
CanonicalMessagePart,
|
|
5
|
+
CanonicalPartType,
|
|
6
|
+
CanonicalRequest,
|
|
7
|
+
CanonicalToolCall,
|
|
8
|
+
CanonicalThinking,
|
|
9
|
+
CompatibilityDecision,
|
|
10
|
+
CompatibilityProfile,
|
|
11
|
+
CompatibilityStatus,
|
|
12
|
+
CompatibilityTrace,
|
|
13
|
+
build_canonical_request,
|
|
14
|
+
)
|
|
15
|
+
from .session_store import CompatSessionRecord, CompatSessionStore
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CanonicalMessagePart",
|
|
19
|
+
"CanonicalPartType",
|
|
20
|
+
"CanonicalRequest",
|
|
21
|
+
"CanonicalThinking",
|
|
22
|
+
"CanonicalToolCall",
|
|
23
|
+
"CompatibilityDecision",
|
|
24
|
+
"CompatibilityProfile",
|
|
25
|
+
"CompatibilityStatus",
|
|
26
|
+
"CompatibilityTrace",
|
|
27
|
+
"CompatSessionRecord",
|
|
28
|
+
"CompatSessionStore",
|
|
29
|
+
"build_canonical_request",
|
|
30
|
+
]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""供应商无关的 Claude / Anthropic 语义抽象.
|
|
2
|
+
|
|
3
|
+
类型定义已迁移至 :mod:`coding.proxy.model.compat`。
|
|
4
|
+
本文件保留 ``build_canonical_request()`` 等构建逻辑,类型通过 re-export 提供。
|
|
5
|
+
|
|
6
|
+
.. deprecated::
|
|
7
|
+
未来版本将移除类型 re-export,请直接从 :mod:`coding.proxy.model.compat` 导入类型。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
# noqa: F401
|
|
18
|
+
from ..model.compat import (
|
|
19
|
+
CanonicalMessagePart,
|
|
20
|
+
CanonicalPartType,
|
|
21
|
+
CanonicalRequest,
|
|
22
|
+
CanonicalThinking,
|
|
23
|
+
CanonicalToolCall,
|
|
24
|
+
CompatibilityDecision,
|
|
25
|
+
CompatibilityProfile,
|
|
26
|
+
CompatibilityStatus,
|
|
27
|
+
CompatibilityTrace,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_canonical_request(
|
|
32
|
+
body: dict[str, Any],
|
|
33
|
+
headers: dict[str, str],
|
|
34
|
+
) -> CanonicalRequest:
|
|
35
|
+
"""从原始请求体和头部构建规范化的 CanonicalRequest."""
|
|
36
|
+
trace_id = str(uuid.uuid4())
|
|
37
|
+
request_id = _extract_request_id(body, headers, trace_id)
|
|
38
|
+
session_key = _derive_session_key(body, headers)
|
|
39
|
+
thinking = _extract_thinking(body)
|
|
40
|
+
messages = _extract_parts(body.get("messages", []))
|
|
41
|
+
metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
|
|
42
|
+
tool_names = [
|
|
43
|
+
str(tool.get("name", ""))
|
|
44
|
+
for tool in body.get("tools", [])
|
|
45
|
+
if isinstance(tool, dict) and tool.get("name")
|
|
46
|
+
]
|
|
47
|
+
response_format = body.get("response_format")
|
|
48
|
+
|
|
49
|
+
return CanonicalRequest(
|
|
50
|
+
session_key=session_key,
|
|
51
|
+
trace_id=trace_id,
|
|
52
|
+
request_id=request_id,
|
|
53
|
+
model=str(body.get("model", "")),
|
|
54
|
+
messages=messages,
|
|
55
|
+
thinking=thinking,
|
|
56
|
+
metadata=metadata,
|
|
57
|
+
tool_names=tool_names,
|
|
58
|
+
supports_json_output=(
|
|
59
|
+
isinstance(response_format, dict)
|
|
60
|
+
and str(response_format.get("type", "")).startswith("json")
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_request_id(body: dict[str, Any], headers: dict[str, str], trace_id: str) -> str:
|
|
66
|
+
for key in ("request_id", "id"):
|
|
67
|
+
value = body.get(key)
|
|
68
|
+
if isinstance(value, str) and value.strip():
|
|
69
|
+
return value.strip()
|
|
70
|
+
for key in ("x-request-id", "request-id"):
|
|
71
|
+
value = headers.get(key)
|
|
72
|
+
if isinstance(value, str) and value.strip():
|
|
73
|
+
return value.strip()
|
|
74
|
+
return trace_id
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _derive_session_key(body: dict[str, Any], headers: dict[str, str]) -> str:
|
|
78
|
+
for key in ("x-claude-session-id", "x-session-id", "session-id"):
|
|
79
|
+
value = headers.get(key)
|
|
80
|
+
if isinstance(value, str) and value.strip():
|
|
81
|
+
return value.strip()
|
|
82
|
+
|
|
83
|
+
metadata = body.get("metadata")
|
|
84
|
+
if isinstance(metadata, dict):
|
|
85
|
+
for key in ("session_id", "conversation_id", "user_id"):
|
|
86
|
+
value = metadata.get(key)
|
|
87
|
+
if isinstance(value, str) and value.strip():
|
|
88
|
+
return value.strip()
|
|
89
|
+
|
|
90
|
+
digest_body = {
|
|
91
|
+
"model": body.get("model"),
|
|
92
|
+
"system": body.get("system"),
|
|
93
|
+
"tools": body.get("tools"),
|
|
94
|
+
"messages": body.get("messages", [])[-6:],
|
|
95
|
+
}
|
|
96
|
+
digest = hashlib.sha256(
|
|
97
|
+
json.dumps(digest_body, ensure_ascii=False, sort_keys=True, default=str).encode()
|
|
98
|
+
).hexdigest()
|
|
99
|
+
return f"compat_{digest[:24]}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_thinking(body: dict[str, Any]) -> CanonicalThinking:
|
|
103
|
+
for source_field in ("thinking", "extended_thinking"):
|
|
104
|
+
value = body.get(source_field)
|
|
105
|
+
if not value:
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return CanonicalThinking(
|
|
109
|
+
enabled=True,
|
|
110
|
+
budget_tokens=value.get("budget_tokens") if isinstance(value.get("budget_tokens"), int) else None,
|
|
111
|
+
effort=str(value.get("effort")) if value.get("effort") else None,
|
|
112
|
+
source_field=source_field,
|
|
113
|
+
)
|
|
114
|
+
return CanonicalThinking(enabled=True, source_field=source_field)
|
|
115
|
+
return CanonicalThinking()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_parts(messages: list[dict[str, Any]]) -> list[CanonicalMessagePart]:
|
|
119
|
+
parts: list[CanonicalMessagePart] = []
|
|
120
|
+
for message in messages:
|
|
121
|
+
if not isinstance(message, dict):
|
|
122
|
+
continue
|
|
123
|
+
role = str(message.get("role", "user"))
|
|
124
|
+
content = message.get("content")
|
|
125
|
+
if isinstance(content, str):
|
|
126
|
+
parts.append(CanonicalMessagePart(type=CanonicalPartType.TEXT, role=role, text=content))
|
|
127
|
+
continue
|
|
128
|
+
if not isinstance(content, list):
|
|
129
|
+
continue
|
|
130
|
+
for block in content:
|
|
131
|
+
if not isinstance(block, dict):
|
|
132
|
+
continue
|
|
133
|
+
block_type = str(block.get("type", ""))
|
|
134
|
+
if block_type == "text":
|
|
135
|
+
parts.append(CanonicalMessagePart(
|
|
136
|
+
type=CanonicalPartType.TEXT,
|
|
137
|
+
role=role,
|
|
138
|
+
text=str(block.get("text", "")),
|
|
139
|
+
raw_block=block,
|
|
140
|
+
))
|
|
141
|
+
elif block_type == "thinking":
|
|
142
|
+
parts.append(CanonicalMessagePart(
|
|
143
|
+
type=CanonicalPartType.THINKING,
|
|
144
|
+
role=role,
|
|
145
|
+
text=str(block.get("thinking", "")),
|
|
146
|
+
raw_block=block,
|
|
147
|
+
))
|
|
148
|
+
elif block_type == "image":
|
|
149
|
+
parts.append(CanonicalMessagePart(
|
|
150
|
+
type=CanonicalPartType.IMAGE,
|
|
151
|
+
role=role,
|
|
152
|
+
raw_block=block,
|
|
153
|
+
))
|
|
154
|
+
elif block_type in {"tool_use", "server_tool_use"}:
|
|
155
|
+
parts.append(CanonicalMessagePart(
|
|
156
|
+
type=CanonicalPartType.TOOL_USE,
|
|
157
|
+
role=role,
|
|
158
|
+
tool_call=CanonicalToolCall(
|
|
159
|
+
tool_id=str(block.get("id", "")),
|
|
160
|
+
name=str(block.get("name", "")),
|
|
161
|
+
arguments=block.get("input", {}) if isinstance(block.get("input"), dict) else {},
|
|
162
|
+
),
|
|
163
|
+
raw_block=block,
|
|
164
|
+
))
|
|
165
|
+
elif block_type == "tool_result":
|
|
166
|
+
parts.append(CanonicalMessagePart(
|
|
167
|
+
type=CanonicalPartType.TOOL_RESULT,
|
|
168
|
+
role=role,
|
|
169
|
+
text=_stringify_tool_result_content(block.get("content")),
|
|
170
|
+
tool_result_id=str(block.get("tool_use_id", "")),
|
|
171
|
+
raw_block=block,
|
|
172
|
+
))
|
|
173
|
+
else:
|
|
174
|
+
parts.append(CanonicalMessagePart(
|
|
175
|
+
type=CanonicalPartType.UNKNOWN,
|
|
176
|
+
role=role,
|
|
177
|
+
raw_block=block,
|
|
178
|
+
))
|
|
179
|
+
return parts
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _stringify_tool_result_content(content: Any) -> str:
|
|
183
|
+
if isinstance(content, str):
|
|
184
|
+
return content
|
|
185
|
+
if isinstance(content, list):
|
|
186
|
+
chunks: list[str] = []
|
|
187
|
+
for block in content:
|
|
188
|
+
if not isinstance(block, dict):
|
|
189
|
+
continue
|
|
190
|
+
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
191
|
+
chunks.append(block["text"])
|
|
192
|
+
return "\n".join(chunks)
|
|
193
|
+
return ""
|