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,88 @@
|
|
|
1
|
+
"""OpenAI Chat Completions → Anthropic Messages 转换."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def convert_response(response: dict[str, Any]) -> dict[str, Any]:
|
|
13
|
+
"""转换 OpenAI chat.completions 响应为 Anthropic message 响应."""
|
|
14
|
+
choices = response.get("choices", [])
|
|
15
|
+
text_blocks: list[dict[str, Any]] = []
|
|
16
|
+
tool_use_blocks: list[dict[str, Any]] = []
|
|
17
|
+
finish_reason = None
|
|
18
|
+
|
|
19
|
+
for choice in choices:
|
|
20
|
+
if not isinstance(choice, dict):
|
|
21
|
+
continue
|
|
22
|
+
finish_reason = choice.get("finish_reason") or finish_reason
|
|
23
|
+
message = choice.get("message", {})
|
|
24
|
+
reasoning_content = message.get("reasoning_content")
|
|
25
|
+
if isinstance(reasoning_content, str) and reasoning_content.strip():
|
|
26
|
+
text_blocks.append({"type": "thinking", "thinking": reasoning_content})
|
|
27
|
+
logger.debug(
|
|
28
|
+
"copilot: response reasoning_content -> thinking block (%d chars)",
|
|
29
|
+
len(reasoning_content),
|
|
30
|
+
)
|
|
31
|
+
content = message.get("content")
|
|
32
|
+
if isinstance(content, str) and content:
|
|
33
|
+
text_blocks.append({"type": "text", "text": content})
|
|
34
|
+
elif isinstance(content, list):
|
|
35
|
+
for part in content:
|
|
36
|
+
if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
|
|
37
|
+
text_blocks.append({"type": "text", "text": part["text"]})
|
|
38
|
+
|
|
39
|
+
for tool_call in message.get("tool_calls", []) or []:
|
|
40
|
+
if not isinstance(tool_call, dict):
|
|
41
|
+
continue
|
|
42
|
+
function = tool_call.get("function", {})
|
|
43
|
+
arguments = function.get("arguments", "{}")
|
|
44
|
+
try:
|
|
45
|
+
parsed_arguments = json.loads(arguments) if isinstance(arguments, str) else arguments
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
parsed_arguments = {}
|
|
48
|
+
tool_use_blocks.append({
|
|
49
|
+
"type": "tool_use",
|
|
50
|
+
"id": tool_call.get("id", ""),
|
|
51
|
+
"name": function.get("name", ""),
|
|
52
|
+
"input": parsed_arguments if isinstance(parsed_arguments, dict) else {},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
usage = response.get("usage", {}) or {}
|
|
56
|
+
cached_tokens = ((usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
|
|
57
|
+
content_blocks = [*text_blocks, *tool_use_blocks]
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"id": response.get("request_id", "") or response.get("id", ""),
|
|
61
|
+
"type": "message",
|
|
62
|
+
"role": "assistant",
|
|
63
|
+
"model": response.get("model", ""),
|
|
64
|
+
"content": content_blocks,
|
|
65
|
+
"stop_reason": _map_stop_reason(finish_reason) or "end_turn",
|
|
66
|
+
"stop_sequence": None,
|
|
67
|
+
"usage": {
|
|
68
|
+
"input_tokens": max((usage.get("prompt_tokens", 0) or 0) - cached_tokens, 0),
|
|
69
|
+
"output_tokens": usage.get("completion_tokens", 0) or 0,
|
|
70
|
+
**({"cache_read_input_tokens": cached_tokens} if cached_tokens else {}),
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _map_stop_reason(reason: str | None) -> str | None:
|
|
76
|
+
if reason is None:
|
|
77
|
+
return None
|
|
78
|
+
mapping = {
|
|
79
|
+
"stop": "end_turn",
|
|
80
|
+
"length": "max_tokens",
|
|
81
|
+
"tool_calls": "tool_use",
|
|
82
|
+
"content_filter": "end_turn",
|
|
83
|
+
}
|
|
84
|
+
mapped = mapping.get(reason)
|
|
85
|
+
if mapped is None:
|
|
86
|
+
logger.debug("copilot: unknown finish_reason '%s', defaulting to end_turn", reason)
|
|
87
|
+
return "end_turn"
|
|
88
|
+
return mapped
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""日志模块."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def build_log_config(level: str = "INFO") -> dict:
|
|
6
|
+
"""构建 uvicorn log_config,使用 yyyy-MM-dd HH:mm:ss 时间格式."""
|
|
7
|
+
return {
|
|
8
|
+
"version": 1,
|
|
9
|
+
"disable_existing_loggers": False,
|
|
10
|
+
"formatters": {
|
|
11
|
+
"default": {
|
|
12
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
13
|
+
"fmt": "%(asctime)s %(levelprefix)s %(message)s",
|
|
14
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
15
|
+
"use_colors": None,
|
|
16
|
+
},
|
|
17
|
+
"access": {
|
|
18
|
+
"()": "uvicorn.logging.AccessFormatter",
|
|
19
|
+
"fmt": '%(asctime)s %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
|
|
20
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
"handlers": {
|
|
24
|
+
"default": {
|
|
25
|
+
"formatter": "default",
|
|
26
|
+
"class": "logging.StreamHandler",
|
|
27
|
+
"stream": "ext://sys.stderr",
|
|
28
|
+
},
|
|
29
|
+
"access": {
|
|
30
|
+
"formatter": "access",
|
|
31
|
+
"class": "logging.StreamHandler",
|
|
32
|
+
"stream": "ext://sys.stdout",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"loggers": {
|
|
36
|
+
"uvicorn": {"handlers": ["default"], "level": level, "propagate": False},
|
|
37
|
+
"uvicorn.error": {"level": level},
|
|
38
|
+
"uvicorn.access": {
|
|
39
|
+
"handlers": ["access"],
|
|
40
|
+
"level": "INFO",
|
|
41
|
+
"propagate": False,
|
|
42
|
+
},
|
|
43
|
+
"coding.proxy": {
|
|
44
|
+
"handlers": ["default"],
|
|
45
|
+
"level": level,
|
|
46
|
+
"propagate": False,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Token 用量 SQLite 日志."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
import aiosqlite
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _local_tz() -> ZoneInfo:
|
|
16
|
+
"""获取系统本地时区,失败降级 UTC."""
|
|
17
|
+
try:
|
|
18
|
+
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
|
19
|
+
except Exception:
|
|
20
|
+
logger.warning("无法获取系统本地时区,降级使用 UTC")
|
|
21
|
+
return timezone.utc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _days_start_utc_iso(days: int) -> str:
|
|
25
|
+
"""
|
|
26
|
+
计算本地时区下「往前推 days-1 天的那天 00:00:00」对应的 UTC ISO 字符串.
|
|
27
|
+
|
|
28
|
+
语义: days=1 → 今天 00:00 local → 转 UTC
|
|
29
|
+
days=7 → 6 天前 00:00 local → 转 UTC
|
|
30
|
+
"""
|
|
31
|
+
tz = _local_tz()
|
|
32
|
+
start_date = datetime.now(tz).date() - timedelta(days=max(1, days) - 1)
|
|
33
|
+
start_dt = datetime(start_date.year, start_date.month, start_date.day, tzinfo=tz)
|
|
34
|
+
return start_dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%f+00:00")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _hours_ago_utc_iso(hours: float) -> str:
|
|
38
|
+
"""计算 hours 小时前的 UTC ISO 字符串(用于滚动窗口)."""
|
|
39
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
40
|
+
return cutoff.strftime("%Y-%m-%dT%H:%M:%f+00:00")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _local_date_udf(ts_str: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
SQLite UDF:将 UTC ISO 时间戳转为本地日期字符串.
|
|
46
|
+
|
|
47
|
+
设计要点:
|
|
48
|
+
- 动态调用 _local_tz()(非闭包捕获),使 unittest.mock.patch 可注入测试时区
|
|
49
|
+
- 容错处理非 ISO 格式(如旧迁移数据 ts='now'),降级为字符串截取前 10 位
|
|
50
|
+
- 永不抛异常(SQLite UDF 异常会导致整个查询失败)
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
tz = _local_tz()
|
|
54
|
+
return datetime.fromisoformat(
|
|
55
|
+
ts_str.replace("Z", "+00:00")
|
|
56
|
+
).astimezone(tz).strftime("%Y-%m-%d")
|
|
57
|
+
except (ValueError, TypeError, AttributeError):
|
|
58
|
+
# 非 ISO 格式(如旧数据 'now')降级为字符串截取前 10 位
|
|
59
|
+
if isinstance(ts_str, str) and len(ts_str) >= 10:
|
|
60
|
+
return ts_str[:10]
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_CREATE_TABLES = """
|
|
65
|
+
CREATE TABLE IF NOT EXISTS usage_log (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
68
|
+
vendor TEXT NOT NULL,
|
|
69
|
+
model_requested TEXT NOT NULL,
|
|
70
|
+
model_served TEXT NOT NULL,
|
|
71
|
+
input_tokens INTEGER DEFAULT 0,
|
|
72
|
+
output_tokens INTEGER DEFAULT 0,
|
|
73
|
+
cache_creation_tokens INTEGER DEFAULT 0,
|
|
74
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
75
|
+
duration_ms INTEGER DEFAULT 0,
|
|
76
|
+
success BOOLEAN NOT NULL DEFAULT 1,
|
|
77
|
+
failover BOOLEAN NOT NULL DEFAULT 0,
|
|
78
|
+
failover_from TEXT DEFAULT NULL,
|
|
79
|
+
request_id TEXT DEFAULT ''
|
|
80
|
+
);
|
|
81
|
+
CREATE TABLE IF NOT EXISTS usage_evidence (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
84
|
+
vendor TEXT NOT NULL,
|
|
85
|
+
request_id TEXT DEFAULT '',
|
|
86
|
+
model_served TEXT NOT NULL DEFAULT '',
|
|
87
|
+
evidence_kind TEXT NOT NULL,
|
|
88
|
+
raw_usage_json TEXT NOT NULL DEFAULT '{}',
|
|
89
|
+
parsed_input_tokens INTEGER DEFAULT 0,
|
|
90
|
+
parsed_output_tokens INTEGER DEFAULT 0,
|
|
91
|
+
parsed_cache_creation_tokens INTEGER DEFAULT 0,
|
|
92
|
+
parsed_cache_read_tokens INTEGER DEFAULT 0,
|
|
93
|
+
cache_signal_present BOOLEAN NOT NULL DEFAULT 0,
|
|
94
|
+
source_field_map_json TEXT NOT NULL DEFAULT '{}'
|
|
95
|
+
);
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_CREATE_INDEXES = """
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_log(ts);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_usage_vendor ON usage_log(vendor);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_usage_evidence_request_id ON usage_evidence(request_id);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_usage_evidence_vendor ON usage_evidence(vendor);
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TokenLogger:
|
|
107
|
+
def __init__(self, db_path: Path) -> None:
|
|
108
|
+
self._db_path = db_path
|
|
109
|
+
self._db: aiosqlite.Connection | None = None
|
|
110
|
+
|
|
111
|
+
async def init(self) -> None:
|
|
112
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
self._db = await aiosqlite.connect(str(self._db_path))
|
|
114
|
+
self._db.row_factory = aiosqlite.Row
|
|
115
|
+
await self._db.execute("PRAGMA journal_mode=WAL")
|
|
116
|
+
await self._db.executescript(_CREATE_TABLES)
|
|
117
|
+
# 迁移必须在建索引之前执行,确保 vendor 列已存在
|
|
118
|
+
await self._migrate_rename_backend_to_vendor()
|
|
119
|
+
await self._migrate_add_failover_from()
|
|
120
|
+
await self._db.executescript(_CREATE_INDEXES)
|
|
121
|
+
# 注册时区感知的日期函数:将 UTC 时间戳转为本地日期
|
|
122
|
+
await self._db.create_function("local_date", 1, _local_date_udf)
|
|
123
|
+
await self._db.commit()
|
|
124
|
+
|
|
125
|
+
async def _migrate_add_failover_from(self) -> None:
|
|
126
|
+
"""幂等迁移:为已有数据库添加 failover_from 列."""
|
|
127
|
+
if not self._db:
|
|
128
|
+
return
|
|
129
|
+
cursor = await self._db.execute("PRAGMA table_info(usage_log)")
|
|
130
|
+
columns = {row["name"] for row in await cursor.fetchall()}
|
|
131
|
+
if "failover_from" not in columns:
|
|
132
|
+
await self._db.execute(
|
|
133
|
+
"ALTER TABLE usage_log ADD COLUMN failover_from TEXT DEFAULT NULL"
|
|
134
|
+
)
|
|
135
|
+
logger.info("Migration: added failover_from column to usage_log")
|
|
136
|
+
|
|
137
|
+
async def _migrate_rename_backend_to_vendor(self) -> None:
|
|
138
|
+
"""幂等迁移:重命名 backend 列为 vendor."""
|
|
139
|
+
if not self._db:
|
|
140
|
+
return
|
|
141
|
+
for table in ("usage_log", "usage_evidence"):
|
|
142
|
+
cursor = await self._db.execute(f"PRAGMA table_info({table})")
|
|
143
|
+
columns = {row["name"] for row in await cursor.fetchall()}
|
|
144
|
+
if "backend" in columns and "vendor" not in columns:
|
|
145
|
+
await self._db.execute(f"ALTER TABLE {table} RENAME COLUMN backend TO vendor")
|
|
146
|
+
logger.info("Migration: renamed 'backend' column to 'vendor' in %s", table)
|
|
147
|
+
|
|
148
|
+
async def log(self, vendor: str, model_requested: str, model_served: str,
|
|
149
|
+
input_tokens: int = 0, output_tokens: int = 0,
|
|
150
|
+
cache_creation_tokens: int = 0, cache_read_tokens: int = 0,
|
|
151
|
+
duration_ms: int = 0, success: bool = True,
|
|
152
|
+
failover: bool = False, failover_from: str | None = None,
|
|
153
|
+
request_id: str = "") -> None:
|
|
154
|
+
if not self._db:
|
|
155
|
+
return
|
|
156
|
+
await self._db.execute(
|
|
157
|
+
"""INSERT INTO usage_log
|
|
158
|
+
(vendor, model_requested, model_served,
|
|
159
|
+
input_tokens, output_tokens,
|
|
160
|
+
cache_creation_tokens, cache_read_tokens,
|
|
161
|
+
duration_ms, success, failover, failover_from, request_id)
|
|
162
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
163
|
+
(vendor, model_requested, model_served,
|
|
164
|
+
input_tokens, output_tokens,
|
|
165
|
+
cache_creation_tokens, cache_read_tokens,
|
|
166
|
+
duration_ms, success, failover, failover_from, request_id))
|
|
167
|
+
await self._db.commit()
|
|
168
|
+
|
|
169
|
+
async def log_evidence(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
vendor: str,
|
|
173
|
+
request_id: str = "",
|
|
174
|
+
model_served: str = "",
|
|
175
|
+
evidence_kind: str,
|
|
176
|
+
raw_usage_json: str,
|
|
177
|
+
parsed_input_tokens: int = 0,
|
|
178
|
+
parsed_output_tokens: int = 0,
|
|
179
|
+
parsed_cache_creation_tokens: int = 0,
|
|
180
|
+
parsed_cache_read_tokens: int = 0,
|
|
181
|
+
cache_signal_present: bool = False,
|
|
182
|
+
source_field_map_json: str = "{}",
|
|
183
|
+
) -> None:
|
|
184
|
+
if not self._db:
|
|
185
|
+
return
|
|
186
|
+
await self._db.execute(
|
|
187
|
+
"""INSERT INTO usage_evidence
|
|
188
|
+
(vendor, request_id, model_served, evidence_kind, raw_usage_json,
|
|
189
|
+
parsed_input_tokens, parsed_output_tokens,
|
|
190
|
+
parsed_cache_creation_tokens, parsed_cache_read_tokens,
|
|
191
|
+
cache_signal_present, source_field_map_json)
|
|
192
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
193
|
+
(
|
|
194
|
+
vendor,
|
|
195
|
+
request_id,
|
|
196
|
+
model_served,
|
|
197
|
+
evidence_kind,
|
|
198
|
+
raw_usage_json,
|
|
199
|
+
parsed_input_tokens,
|
|
200
|
+
parsed_output_tokens,
|
|
201
|
+
parsed_cache_creation_tokens,
|
|
202
|
+
parsed_cache_read_tokens,
|
|
203
|
+
cache_signal_present,
|
|
204
|
+
source_field_map_json,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
await self._db.commit()
|
|
208
|
+
|
|
209
|
+
async def query_evidence(self, request_id: str) -> list[dict]:
|
|
210
|
+
if not self._db:
|
|
211
|
+
return []
|
|
212
|
+
cursor = await self._db.execute(
|
|
213
|
+
"""SELECT vendor, request_id, model_served, evidence_kind, raw_usage_json,
|
|
214
|
+
parsed_input_tokens, parsed_output_tokens,
|
|
215
|
+
parsed_cache_creation_tokens, parsed_cache_read_tokens,
|
|
216
|
+
cache_signal_present, source_field_map_json
|
|
217
|
+
FROM usage_evidence
|
|
218
|
+
WHERE request_id = ?
|
|
219
|
+
ORDER BY id ASC""",
|
|
220
|
+
(request_id,),
|
|
221
|
+
)
|
|
222
|
+
rows = await cursor.fetchall()
|
|
223
|
+
return [dict(row) for row in rows]
|
|
224
|
+
|
|
225
|
+
async def query_daily(self, days: int = 7, vendor: str | None = None,
|
|
226
|
+
model: str | None = None) -> list[dict]:
|
|
227
|
+
if not self._db:
|
|
228
|
+
return []
|
|
229
|
+
days = max(1, days)
|
|
230
|
+
start_iso = _days_start_utc_iso(days)
|
|
231
|
+
sql = """SELECT local_date(ts) AS date, vendor, model_requested, model_served,
|
|
232
|
+
COUNT(*) AS total_requests,
|
|
233
|
+
SUM(input_tokens) AS total_input,
|
|
234
|
+
SUM(output_tokens) AS total_output,
|
|
235
|
+
SUM(cache_creation_tokens) AS total_cache_creation,
|
|
236
|
+
SUM(cache_read_tokens) AS total_cache_read,
|
|
237
|
+
SUM(CASE WHEN failover THEN 1 ELSE 0 END) AS total_failovers,
|
|
238
|
+
AVG(duration_ms) AS avg_duration_ms
|
|
239
|
+
FROM usage_log WHERE ts >= ?"""
|
|
240
|
+
params: list = [start_iso]
|
|
241
|
+
if vendor:
|
|
242
|
+
sql += " AND vendor = ?"
|
|
243
|
+
params.append(vendor)
|
|
244
|
+
if model:
|
|
245
|
+
sql += " AND model_requested = ?"
|
|
246
|
+
params.append(model)
|
|
247
|
+
sql += (" GROUP BY local_date(ts), vendor, model_requested, model_served"
|
|
248
|
+
" ORDER BY local_date(ts) DESC, vendor, model_requested, model_served")
|
|
249
|
+
cursor = await self._db.execute(sql, params)
|
|
250
|
+
rows = await cursor.fetchall()
|
|
251
|
+
return [dict(row) for row in rows]
|
|
252
|
+
|
|
253
|
+
async def query_failover_stats(self, days: int = 7, include_model_info: bool = False) -> list[dict]:
|
|
254
|
+
"""
|
|
255
|
+
按 failover_from → vendor 聚合故障转移次数.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
days: 查询天数
|
|
259
|
+
include_model_info: 是否在聚合中包含模型信息
|
|
260
|
+
- False: 按 (failover_from, vendor) 聚合 (默认,向后兼容)
|
|
261
|
+
- True: 按 (failover_from, vendor, model_requested, model_served) 聚合
|
|
262
|
+
"""
|
|
263
|
+
if not self._db:
|
|
264
|
+
return []
|
|
265
|
+
days = max(1, days)
|
|
266
|
+
start_iso = _days_start_utc_iso(days)
|
|
267
|
+
|
|
268
|
+
if include_model_info:
|
|
269
|
+
sql = """SELECT failover_from, vendor, model_requested, model_served,
|
|
270
|
+
COUNT(*) AS count
|
|
271
|
+
FROM usage_log
|
|
272
|
+
WHERE failover = 1 AND ts >= ?
|
|
273
|
+
GROUP BY failover_from, vendor, model_requested, model_served
|
|
274
|
+
ORDER BY count DESC"""
|
|
275
|
+
else:
|
|
276
|
+
# 保持原有的聚合逻辑确保向后兼容
|
|
277
|
+
sql = """SELECT failover_from, vendor,
|
|
278
|
+
COUNT(*) AS count
|
|
279
|
+
FROM usage_log
|
|
280
|
+
WHERE failover = 1 AND ts >= ?
|
|
281
|
+
GROUP BY failover_from, vendor
|
|
282
|
+
ORDER BY count DESC"""
|
|
283
|
+
|
|
284
|
+
cursor = await self._db.execute(sql, [start_iso])
|
|
285
|
+
rows = await cursor.fetchall()
|
|
286
|
+
return [dict(row) for row in rows]
|
|
287
|
+
|
|
288
|
+
async def query_window_total(
|
|
289
|
+
self, window_hours: float, vendor: str = "anthropic",
|
|
290
|
+
) -> int:
|
|
291
|
+
"""查询滚动时间窗口内指定供应商的 token 总用量."""
|
|
292
|
+
if not self._db:
|
|
293
|
+
return 0
|
|
294
|
+
cutoff_iso = _hours_ago_utc_iso(window_hours)
|
|
295
|
+
cursor = await self._db.execute(
|
|
296
|
+
"""SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
|
|
297
|
+
FROM usage_log
|
|
298
|
+
WHERE vendor = ? AND success = 1
|
|
299
|
+
AND ts >= ?""",
|
|
300
|
+
(vendor, cutoff_iso),
|
|
301
|
+
)
|
|
302
|
+
row = await cursor.fetchone()
|
|
303
|
+
return row["total"] if row else 0
|
|
304
|
+
|
|
305
|
+
async def close(self) -> None:
|
|
306
|
+
if self._db:
|
|
307
|
+
await self._db.close()
|
|
308
|
+
self._db = None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""使用统计查询与展示."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .db import TokenLogger
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..pricing import PricingTable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _format_model_display(model_value: str | None) -> str:
|
|
17
|
+
"""格式化模型显示,处理 None 或空值."""
|
|
18
|
+
if not model_value or model_value.strip() == "":
|
|
19
|
+
return "[dim]<未知>[/dim]"
|
|
20
|
+
return model_value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _format_tokens(n: int) -> str:
|
|
24
|
+
"""将 Token 数量格式化为 K/M/B 计量单位显示(最多 2 位小数)."""
|
|
25
|
+
if n >= 1_000_000_000:
|
|
26
|
+
return f"{n / 1_000_000_000:.2f}".rstrip("0").rstrip(".") + "B"
|
|
27
|
+
if n >= 1_000_000:
|
|
28
|
+
return f"{n / 1_000_000:.2f}".rstrip("0").rstrip(".") + "M"
|
|
29
|
+
if n >= 1_000:
|
|
30
|
+
return f"{n / 1_000:.2f}".rstrip("0").rstrip(".") + "K"
|
|
31
|
+
return str(n)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _detect_model_variants(failover_stats: list[dict]) -> bool:
|
|
35
|
+
"""检测是否存在模型差异,用于决定是否建议详细模式."""
|
|
36
|
+
if not failover_stats or "model_requested" not in failover_stats[0]:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
# 计算唯一的模型对
|
|
40
|
+
model_pairs = {
|
|
41
|
+
(stat.get("model_requested", ""), stat.get("model_served", ""))
|
|
42
|
+
for stat in failover_stats
|
|
43
|
+
}
|
|
44
|
+
# 检查是否存在模型映射(请求模型与实际模型不同)
|
|
45
|
+
return any(
|
|
46
|
+
pair[0] != pair[1]
|
|
47
|
+
for pair in model_pairs
|
|
48
|
+
if pair[0] and pair[1]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def show_usage(
|
|
53
|
+
logger: TokenLogger,
|
|
54
|
+
days: int = 7,
|
|
55
|
+
vendor: str | None = None,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
pricing_table: "PricingTable | None" = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""展示 Token 使用统计."""
|
|
60
|
+
console = Console()
|
|
61
|
+
rows = await logger.query_daily(days=days, vendor=vendor, model=model)
|
|
62
|
+
|
|
63
|
+
if not rows:
|
|
64
|
+
console.print("[yellow]暂无使用记录[/yellow]")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
table = Table(title=f"Token 使用统计(最近 {days} 天)")
|
|
68
|
+
table.add_column("日期", style="cyan")
|
|
69
|
+
table.add_column("供应商", style="green")
|
|
70
|
+
table.add_column("请求模型", style="magenta")
|
|
71
|
+
table.add_column("实际模型", style="yellow")
|
|
72
|
+
table.add_column("请求数", justify="right")
|
|
73
|
+
table.add_column("输入 Token", justify="right", style="blue")
|
|
74
|
+
table.add_column("输出 Token", justify="right", style="blue")
|
|
75
|
+
table.add_column("缓存创建 Token", justify="right", style="dim blue")
|
|
76
|
+
table.add_column("缓存读取 Token", justify="right", style="dim cyan")
|
|
77
|
+
table.add_column("总 Token", justify="right", style="bold white")
|
|
78
|
+
table.add_column("Cost", justify="right", style="bold green")
|
|
79
|
+
table.add_column("平均耗时(ms)", justify="right")
|
|
80
|
+
|
|
81
|
+
for row in rows:
|
|
82
|
+
total_input = row.get("total_input", 0) or 0
|
|
83
|
+
total_output = row.get("total_output", 0) or 0
|
|
84
|
+
total_cache_creation = row.get("total_cache_creation", 0) or 0
|
|
85
|
+
total_cache_read = row.get("total_cache_read", 0) or 0
|
|
86
|
+
total_tokens = total_input + total_output + total_cache_creation + total_cache_read
|
|
87
|
+
|
|
88
|
+
vendor_name = str(row.get("vendor", ""))
|
|
89
|
+
model_served = str(row.get("model_served", ""))
|
|
90
|
+
if pricing_table is not None:
|
|
91
|
+
cost_value = pricing_table.compute_cost(
|
|
92
|
+
vendor_name, model_served,
|
|
93
|
+
total_input, total_output, total_cache_creation, total_cache_read,
|
|
94
|
+
)
|
|
95
|
+
cost_str = cost_value.format() if cost_value is not None else "-"
|
|
96
|
+
else:
|
|
97
|
+
cost_str = "-"
|
|
98
|
+
|
|
99
|
+
table.add_row(
|
|
100
|
+
str(row.get("date", "")),
|
|
101
|
+
vendor_name,
|
|
102
|
+
str(row.get("model_requested", "")),
|
|
103
|
+
model_served,
|
|
104
|
+
str(row.get("total_requests", 0)),
|
|
105
|
+
_format_tokens(total_input),
|
|
106
|
+
_format_tokens(total_output),
|
|
107
|
+
_format_tokens(total_cache_creation),
|
|
108
|
+
_format_tokens(total_cache_read),
|
|
109
|
+
_format_tokens(total_tokens),
|
|
110
|
+
cost_str,
|
|
111
|
+
str(int(row.get("avg_duration_ms", 0) or 0)),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
console.print(table)
|
|
115
|
+
|
|
116
|
+
# 故障转移来源汇总
|
|
117
|
+
failover_stats = await logger.query_failover_stats(days=days)
|
|
118
|
+
if failover_stats:
|
|
119
|
+
console.print()
|
|
120
|
+
ft_table = Table(title="故障转移来源明细")
|
|
121
|
+
ft_table.add_column("来源", style="yellow")
|
|
122
|
+
ft_table.add_column("目标", style="green")
|
|
123
|
+
ft_table.add_column("次数", justify="right", style="red")
|
|
124
|
+
for stat in failover_stats:
|
|
125
|
+
source = stat.get("failover_from") or "unknown"
|
|
126
|
+
target = stat.get("vendor", "")
|
|
127
|
+
count = stat.get("count", 0)
|
|
128
|
+
ft_table.add_row(source, target, str(count))
|
|
129
|
+
console.print(ft_table)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""集中化数据模型 — coding-proxy 所有共享类型的单一事实源.
|
|
2
|
+
|
|
3
|
+
本模块将分散在各处的类型定义(供应商类型、兼容层抽象、认证凭证、
|
|
4
|
+
Token 管理、定价模型、共享常量)统一收归于此,遵循正交分解原则
|
|
5
|
+
按职责域划分子模块。
|
|
6
|
+
|
|
7
|
+
使用者可直接从本包导入::
|
|
8
|
+
|
|
9
|
+
from coding.proxy.model import UsageInfo, VendorResponse, CanonicalRequest
|
|
10
|
+
|
|
11
|
+
也可从具体子模块导入以获得更细粒度的依赖控制::
|
|
12
|
+
|
|
13
|
+
from coding.proxy.model.vendor import UsageInfo, VendorResponse
|
|
14
|
+
from coding.proxy.model.compat import CanonicalRequest, CompatibilityProfile
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# ── 供应商核心类型 ──────────────────────────────────────────
|
|
18
|
+
from .vendor import ( # noqa: F401
|
|
19
|
+
VendorCapabilities,
|
|
20
|
+
VendorResponse,
|
|
21
|
+
CapabilityLossReason,
|
|
22
|
+
CopilotExchangeDiagnostics,
|
|
23
|
+
CopilotMisdirectedRequest,
|
|
24
|
+
CopilotModelCatalog,
|
|
25
|
+
NoCompatibleVendorError,
|
|
26
|
+
RequestCapabilities,
|
|
27
|
+
UsageInfo,
|
|
28
|
+
decode_json_body,
|
|
29
|
+
extract_error_message,
|
|
30
|
+
sanitize_headers_for_synthetic_response,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ── 向后兼容别名(v2 移除)────────────────────────────────────
|
|
34
|
+
BackendCapabilities = VendorCapabilities # noqa: F401
|
|
35
|
+
BackendResponse = VendorResponse # noqa: F401
|
|
36
|
+
NoCompatibleBackendError = NoCompatibleVendorError # noqa: F401
|
|
37
|
+
|
|
38
|
+
# ── 兼容层抽象类型 ────────────────────────────────────────
|
|
39
|
+
from .compat import ( # noqa: F401
|
|
40
|
+
CanonicalMessagePart,
|
|
41
|
+
CanonicalPartType,
|
|
42
|
+
CanonicalRequest,
|
|
43
|
+
CanonicalThinking,
|
|
44
|
+
CanonicalToolCall,
|
|
45
|
+
CompatSessionRecord,
|
|
46
|
+
CompatibilityDecision,
|
|
47
|
+
CompatibilityProfile,
|
|
48
|
+
CompatibilityStatus,
|
|
49
|
+
CompatibilityTrace,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ── 认证凭证模型 ──────────────────────────────────────────
|
|
53
|
+
from .auth import ProviderTokens # noqa: F401
|
|
54
|
+
|
|
55
|
+
# ── Token 管理类型 ──────────────────────────────────────────
|
|
56
|
+
from .token import ( # noqa: F401
|
|
57
|
+
TokenAcquireError,
|
|
58
|
+
TokenErrorKind,
|
|
59
|
+
TokenManagerDiagnostics,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# ── 定价模型 ────────────────────────────────────────────────
|
|
63
|
+
from .pricing import CostValue, Currency, ModelPricing # noqa: F401
|
|
64
|
+
|
|
65
|
+
# ── 共享常量 ────────────────────────────────────────────────
|
|
66
|
+
from .constants import ( # noqa: F401
|
|
67
|
+
PROXY_SKIP_HEADERS,
|
|
68
|
+
RESPONSE_SANITIZE_SKIP_HEADERS,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
# vendor(新命名)
|
|
73
|
+
"VendorCapabilities", "VendorResponse", "NoCompatibleVendorError",
|
|
74
|
+
# 向后兼容别名
|
|
75
|
+
"BackendCapabilities", "BackendResponse", "NoCompatibleBackendError",
|
|
76
|
+
# 通用类型
|
|
77
|
+
"CapabilityLossReason", "CopilotExchangeDiagnostics",
|
|
78
|
+
"CopilotMisdirectedRequest", "CopilotModelCatalog",
|
|
79
|
+
"RequestCapabilities", "UsageInfo",
|
|
80
|
+
"decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response",
|
|
81
|
+
# compat
|
|
82
|
+
"CanonicalMessagePart", "CanonicalPartType", "CanonicalRequest",
|
|
83
|
+
"CanonicalThinking", "CanonicalToolCall", "CompatSessionRecord",
|
|
84
|
+
"CompatibilityDecision", "CompatibilityProfile", "CompatibilityStatus", "CompatibilityTrace",
|
|
85
|
+
# auth
|
|
86
|
+
"ProviderTokens",
|
|
87
|
+
# token
|
|
88
|
+
"TokenAcquireError", "TokenErrorKind", "TokenManagerDiagnostics",
|
|
89
|
+
# pricing
|
|
90
|
+
"CostValue", "Currency", "ModelPricing",
|
|
91
|
+
# constants
|
|
92
|
+
"PROXY_SKIP_HEADERS", "RESPONSE_SANITIZE_SKIP_HEADERS",
|
|
93
|
+
]
|