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,32 @@
|
|
|
1
|
+
"""认证凭证数据模型.
|
|
2
|
+
|
|
3
|
+
从 :mod:`coding.proxy.auth.store` 正交提取 ``ProviderTokens`` Pydantic model。
|
|
4
|
+
``TokenStoreManager`` 持久化管理器保留在原模块。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProviderTokens(BaseModel):
|
|
15
|
+
"""单个 Provider 的 Token 凭证."""
|
|
16
|
+
|
|
17
|
+
access_token: str = ""
|
|
18
|
+
refresh_token: str = ""
|
|
19
|
+
expires_at: float = 0.0 # Unix timestamp
|
|
20
|
+
scope: str = ""
|
|
21
|
+
token_type: str = "bearer"
|
|
22
|
+
extra: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_expired(self) -> bool:
|
|
26
|
+
"""检查 access_token 是否已过期(含 60 秒余量)."""
|
|
27
|
+
return self.expires_at > 0 and __import__("time").time() > self.expires_at - 60
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def has_credentials(self) -> bool:
|
|
31
|
+
"""是否有可用凭证(access_token 或 refresh_token)."""
|
|
32
|
+
return bool(self.access_token or self.refresh_token)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""兼容层抽象类型 — 供应商无关的 Claude/Anthropic 语义模型.
|
|
2
|
+
|
|
3
|
+
从 :mod:`coding.proxy.compat.canonical` 和
|
|
4
|
+
:mod:`coding.proxy.compat.session_store` 正交提取纯声明式类型定义。
|
|
5
|
+
构建逻辑(如 ``build_canonical_request()``)和持久化管理器(如
|
|
6
|
+
``CompatSessionStore``)保留在原模块。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ═══════════════════════════════════════════════════════════════
|
|
17
|
+
# 消息部分类型体系
|
|
18
|
+
# ═══════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CanonicalPartType(str, Enum):
|
|
22
|
+
"""规范消息部分的类型枚举."""
|
|
23
|
+
|
|
24
|
+
TEXT = "text"
|
|
25
|
+
THINKING = "thinking"
|
|
26
|
+
IMAGE = "image"
|
|
27
|
+
TOOL_USE = "tool_use"
|
|
28
|
+
TOOL_RESULT = "tool_result"
|
|
29
|
+
UNKNOWN = "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CanonicalThinking:
|
|
34
|
+
"""思考(extended thinking)能力参数."""
|
|
35
|
+
|
|
36
|
+
enabled: bool = False
|
|
37
|
+
budget_tokens: int | None = None
|
|
38
|
+
effort: str | None = None
|
|
39
|
+
source_field: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CanonicalToolCall:
|
|
44
|
+
"""工具调用记录."""
|
|
45
|
+
|
|
46
|
+
tool_id: str
|
|
47
|
+
name: str
|
|
48
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
provider_tool_id: str | None = None
|
|
50
|
+
provider_kind: str = "function"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CanonicalMessagePart:
|
|
55
|
+
"""规范化的消息内容块."""
|
|
56
|
+
|
|
57
|
+
type: CanonicalPartType
|
|
58
|
+
role: str
|
|
59
|
+
text: str = ""
|
|
60
|
+
tool_call: CanonicalToolCall | None = None
|
|
61
|
+
tool_result_id: str | None = None
|
|
62
|
+
raw_block: dict[str, Any] | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CanonicalRequest:
|
|
67
|
+
"""规范化的完整请求抽象."""
|
|
68
|
+
|
|
69
|
+
session_key: str
|
|
70
|
+
trace_id: str
|
|
71
|
+
request_id: str
|
|
72
|
+
model: str
|
|
73
|
+
messages: list[CanonicalMessagePart]
|
|
74
|
+
thinking: CanonicalThinking
|
|
75
|
+
metadata: dict[str, Any]
|
|
76
|
+
tool_names: list[str]
|
|
77
|
+
supports_json_output: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ═══════════════════════════════════════════════════════════════
|
|
81
|
+
# 兼容性评估类型体系
|
|
82
|
+
# ═══════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CompatibilityStatus(str, Enum):
|
|
86
|
+
"""供应商对某语义特性的兼容状态."""
|
|
87
|
+
|
|
88
|
+
NATIVE = "native"
|
|
89
|
+
SIMULATED = "simulated"
|
|
90
|
+
UNSAFE = "unsafe"
|
|
91
|
+
UNKNOWN = "unknown"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class CompatibilityProfile:
|
|
96
|
+
"""供应商各维度的兼容性画像."""
|
|
97
|
+
|
|
98
|
+
thinking: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
99
|
+
tool_calling: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
100
|
+
tool_streaming: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
101
|
+
mcp_tools: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
102
|
+
images: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
103
|
+
metadata: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
104
|
+
json_output: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
105
|
+
usage_tokens: CompatibilityStatus = CompatibilityStatus.UNKNOWN
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class CompatibilityDecision:
|
|
110
|
+
"""单次请求的兼容性决策结果."""
|
|
111
|
+
|
|
112
|
+
status: CompatibilityStatus
|
|
113
|
+
simulation_actions: list[str] = field(default_factory=list)
|
|
114
|
+
unsupported_semantics: list[str] = field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class CompatibilityTrace:
|
|
119
|
+
"""兼容性处理链路追踪记录."""
|
|
120
|
+
|
|
121
|
+
trace_id: str
|
|
122
|
+
vendor: str
|
|
123
|
+
session_key: str
|
|
124
|
+
provider_protocol: str
|
|
125
|
+
compat_mode: str
|
|
126
|
+
simulation_actions: list[str] = field(default_factory=list)
|
|
127
|
+
unsupported_semantics: list[str] = field(default_factory=list)
|
|
128
|
+
session_state_hits: int = 0
|
|
129
|
+
request_adaptations: list[str] = field(default_factory=list)
|
|
130
|
+
generated_at_unix: int = field(default_factory=lambda: int(__import__("time").time()))
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
from dataclasses import asdict
|
|
134
|
+
|
|
135
|
+
return asdict(self)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ═══════════════════════════════════════════════════════════════
|
|
139
|
+
# 会话状态记录
|
|
140
|
+
# ═══════════════════════════════════════════════════════════════
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class CompatSessionRecord:
|
|
145
|
+
"""兼容层会话持久化记录."""
|
|
146
|
+
|
|
147
|
+
session_key: str
|
|
148
|
+
trace_id: str = ""
|
|
149
|
+
tool_call_map: dict[str, str] = field(default_factory=dict)
|
|
150
|
+
thought_signature_map: dict[str, str] = field(default_factory=dict)
|
|
151
|
+
provider_state: dict[str, Any] = field(default_factory=dict)
|
|
152
|
+
state_version: int = 1
|
|
153
|
+
updated_at_unix: int = 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""跨模块共享常量 — 协议级头部过滤规则与 Copilot 元数据."""
|
|
2
|
+
|
|
3
|
+
# ── 代理转发头过滤规则 ─────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
# 代理转发时应跳过的 hop-by-hop 请求头
|
|
6
|
+
PROXY_SKIP_HEADERS: frozenset[str] = frozenset({
|
|
7
|
+
"host", "content-length", "transfer-encoding", "connection",
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
# 构造合成 Response 时需移除的头部(避免 httpx 二次解压已解压内容)
|
|
11
|
+
RESPONSE_SANITIZE_SKIP_HEADERS: frozenset[str] = frozenset({
|
|
12
|
+
"content-encoding", "content-length", "transfer-encoding",
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
# ── Copilot URL / 版本常量 ─────────────────────────────────
|
|
16
|
+
|
|
17
|
+
_COPILOT_VERSION = "0.26.7"
|
|
18
|
+
_EDITOR_VERSION = "vscode/1.98.0"
|
|
19
|
+
_EDITOR_PLUGIN_VERSION = f"copilot-chat/{_COPILOT_VERSION}"
|
|
20
|
+
_USER_AGENT = f"GitHubCopilotChat/{_COPILOT_VERSION}"
|
|
21
|
+
_GITHUB_API_VERSION = "2025-04-01"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""定价数据模型.
|
|
2
|
+
|
|
3
|
+
从 :mod:`coding.proxy.pricing` 正交提取 ``ModelPricing`` dataclass。
|
|
4
|
+
``PricingTable`` 查询与计算逻辑保留在原模块。
|
|
5
|
+
|
|
6
|
+
本模块同时定义 ``Currency`` 枚举和 ``CostValue`` 值对象,
|
|
7
|
+
支撑双币种(USD/CNY)计费能力。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Currency(StrEnum):
|
|
17
|
+
"""支持的币种."""
|
|
18
|
+
|
|
19
|
+
USD = "USD"
|
|
20
|
+
CNY = "CNY"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def symbol(self) -> str:
|
|
24
|
+
"""货币显示符号."""
|
|
25
|
+
if self is Currency.USD:
|
|
26
|
+
return "$"
|
|
27
|
+
# CNY 及未来扩展币种
|
|
28
|
+
return "\u00a5" # ¥ (U+00A5)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def default(cls) -> "Currency":
|
|
32
|
+
"""默认币种(向后兼容:无前缀视为 USD)."""
|
|
33
|
+
return cls.USD
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# 模块级常量:币种 → 符号映射(供外部查询使用)
|
|
37
|
+
_CURRENCY_SYMBOL_MAP: dict[Currency, str] = {
|
|
38
|
+
Currency.USD: "$",
|
|
39
|
+
Currency.CNY: "\u00a5",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CostValue:
|
|
45
|
+
"""带币种标注的费用值(Value Object,不可变).
|
|
46
|
+
|
|
47
|
+
遵循 Value Object 模式:通过 ``(amount, currency)`` 判等,不可变。
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
amount: float
|
|
51
|
+
currency: Currency = Currency.default()
|
|
52
|
+
|
|
53
|
+
def format(self, precision: int = 4) -> str:
|
|
54
|
+
"""格式化为 ``$0.1234`` 或 ``¥0.1234``."""
|
|
55
|
+
return f"{self.currency.symbol}{self.amount:.{precision}f}"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def symbol(self) -> str:
|
|
59
|
+
return self.currency.symbol
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ModelPricing:
|
|
64
|
+
"""单个模型的 Token 单价(含币种信息)."""
|
|
65
|
+
|
|
66
|
+
currency: Currency = Currency.default()
|
|
67
|
+
input_cost_per_token: float = 0.0
|
|
68
|
+
output_cost_per_token: float = 0.0
|
|
69
|
+
cache_creation_input_token_cost: float = 0.0
|
|
70
|
+
cache_read_input_token_cost: float = 0.0
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Token 管理相关类型 — 枚举、异常与诊断数据类.
|
|
2
|
+
|
|
3
|
+
从 :mod:`coding.proxy.vendors.token_manager` 正交提取纯声明式类型定义。
|
|
4
|
+
``BaseTokenManager`` 抽象基类保留在原模块。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenErrorKind(Enum):
|
|
14
|
+
"""Token 获取失败分类."""
|
|
15
|
+
|
|
16
|
+
TEMPORARY = "temporary"
|
|
17
|
+
INVALID_CREDENTIALS = "invalid_credentials"
|
|
18
|
+
PERMISSION_UPGRADE_REQUIRED = "permission_upgrade_required"
|
|
19
|
+
INSUFFICIENT_SCOPE = "insufficient_scope"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenAcquireError(Exception):
|
|
23
|
+
"""Token 获取失败.
|
|
24
|
+
|
|
25
|
+
needs_reauth=True 表示长期凭证已失效,需要重新执行浏览器 OAuth 登录。
|
|
26
|
+
needs_reauth=False 表示临时性故障(网络超时等),可自动恢复。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, *, needs_reauth: bool = False) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.needs_reauth = needs_reauth
|
|
32
|
+
self.kind = TokenErrorKind.TEMPORARY
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def with_kind(
|
|
36
|
+
cls,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
kind: TokenErrorKind,
|
|
40
|
+
needs_reauth: bool = False,
|
|
41
|
+
) -> "TokenAcquireError":
|
|
42
|
+
err = cls(message, needs_reauth=needs_reauth)
|
|
43
|
+
err.kind = kind
|
|
44
|
+
return err
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TokenManagerDiagnostics:
|
|
49
|
+
"""TokenManager 最近一次失败诊断信息."""
|
|
50
|
+
|
|
51
|
+
last_error: str = ""
|
|
52
|
+
needs_reauth: bool = False
|
|
53
|
+
error_kind: str = ""
|
|
54
|
+
updated_at: float = 0.0
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, str | bool]:
|
|
57
|
+
if not self.last_error:
|
|
58
|
+
return {}
|
|
59
|
+
return {
|
|
60
|
+
"last_error": self.last_error,
|
|
61
|
+
"needs_reauth": self.needs_reauth,
|
|
62
|
+
"error_kind": self.error_kind,
|
|
63
|
+
"updated_at_unix": round(self.updated_at, 3),
|
|
64
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""供应商核心数据模型 — 类型定义、常量引用与工具函数.
|
|
2
|
+
|
|
3
|
+
从本模块正交提取,遵循单一职责原则:
|
|
4
|
+
- 数据类型:UsageInfo / CapabilityLossReason / RequestCapabilities /
|
|
5
|
+
VendorCapabilities / VendorResponse / NoCompatibleVendorError
|
|
6
|
+
- Copilot 诊断数据类:CopilotMisdirectedRequest / CopilotExchangeDiagnostics /
|
|
7
|
+
CopilotModelCatalog
|
|
8
|
+
- 工具函数:JSON 解析、错误消息提取、响应头清洗
|
|
9
|
+
- 常量引用:自 :mod:`coding.proxy.model.constants` 重导出
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .constants import PROXY_SKIP_HEADERS, RESPONSE_SANITIZE_SKIP_HEADERS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ═══════════════════════════════════════════════════════════════
|
|
25
|
+
# 工具函数(公开 API,去除原 _ 前缀)
|
|
26
|
+
# ═══════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sanitize_headers_for_synthetic_response(headers: httpx.Headers) -> dict[str, str]:
|
|
30
|
+
"""移除 content-encoding 等头部,避免合成 httpx.Response 时触发二次解压."""
|
|
31
|
+
return {k: v for k, v in headers.items() if k.lower() not in RESPONSE_SANITIZE_SKIP_HEADERS}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_json_body(response: httpx.Response) -> dict[str, Any] | list[Any] | None:
|
|
35
|
+
"""安全解析 JSON 响应.
|
|
36
|
+
|
|
37
|
+
若 content-type 未声明 JSON 或内容非法,返回 None,而不是抛 JSONDecodeError。
|
|
38
|
+
"""
|
|
39
|
+
if not response.content:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
43
|
+
if "json" not in content_type:
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(response.content)
|
|
46
|
+
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return response.json()
|
|
51
|
+
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_error_message(response: httpx.Response, resp_body: dict[str, Any] | list[Any] | None) -> str | None:
|
|
56
|
+
"""从 HTTP 响应中提取可读错误消息."""
|
|
57
|
+
if isinstance(resp_body, dict):
|
|
58
|
+
error = resp_body.get("error")
|
|
59
|
+
if isinstance(error, dict):
|
|
60
|
+
return error.get("message")
|
|
61
|
+
if isinstance(error, str):
|
|
62
|
+
return error
|
|
63
|
+
message = resp_body.get("message")
|
|
64
|
+
if isinstance(message, str):
|
|
65
|
+
return message
|
|
66
|
+
|
|
67
|
+
if not response.content:
|
|
68
|
+
return None
|
|
69
|
+
text = response.text.strip()
|
|
70
|
+
return text[:500] if text else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ═══════════════════════════════════════════════════════════════
|
|
74
|
+
# 供应商核心数据类型
|
|
75
|
+
# ═══════════════════════════════════════════════════════════════
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class UsageInfo:
|
|
80
|
+
"""一次调用的 Token 用量."""
|
|
81
|
+
|
|
82
|
+
input_tokens: int = 0
|
|
83
|
+
output_tokens: int = 0
|
|
84
|
+
cache_creation_tokens: int = 0
|
|
85
|
+
cache_read_tokens: int = 0
|
|
86
|
+
request_id: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CapabilityLossReason(Enum):
|
|
90
|
+
"""请求语义与供应商能力不匹配的原因."""
|
|
91
|
+
|
|
92
|
+
TOOLS = "tools"
|
|
93
|
+
THINKING = "thinking"
|
|
94
|
+
IMAGES = "images"
|
|
95
|
+
VENDOR_TOOLS = "vendor_tools"
|
|
96
|
+
METADATA = "metadata"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class RequestCapabilities:
|
|
101
|
+
"""一次请求实际使用到的能力画像."""
|
|
102
|
+
|
|
103
|
+
has_tools: bool = False
|
|
104
|
+
has_thinking: bool = False
|
|
105
|
+
has_images: bool = False
|
|
106
|
+
has_metadata: bool = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class VendorCapabilities:
|
|
111
|
+
"""供应商能力声明."""
|
|
112
|
+
|
|
113
|
+
supports_tools: bool = True
|
|
114
|
+
supports_thinking: bool = True
|
|
115
|
+
supports_images: bool = True
|
|
116
|
+
emits_vendor_tool_events: bool = False
|
|
117
|
+
supports_metadata: bool = True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class VendorResponse:
|
|
122
|
+
"""供应商响应结果."""
|
|
123
|
+
|
|
124
|
+
status_code: int = 200
|
|
125
|
+
usage: UsageInfo = field(default_factory=UsageInfo)
|
|
126
|
+
is_streaming: bool = False
|
|
127
|
+
raw_body: bytes = b"{}"
|
|
128
|
+
error_type: str | None = None
|
|
129
|
+
error_message: str | None = None
|
|
130
|
+
model_served: str | None = None
|
|
131
|
+
response_headers: dict[str, str] = field(default_factory=dict)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class NoCompatibleVendorError(RuntimeError):
|
|
135
|
+
"""当前请求没有可安全承接的供应商."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, message: str, *, reasons: list[str] | None = None) -> None:
|
|
138
|
+
super().__init__(message)
|
|
139
|
+
self.reasons = reasons or []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ═══════════════════════════════════════════════════════════════
|
|
143
|
+
# Copilot 诊断数据类
|
|
144
|
+
# ═══════════════════════════════════════════════════════════════
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class CopilotMisdirectedRequest:
|
|
149
|
+
"""Copilot 421 Misdirected 请求诊断载体."""
|
|
150
|
+
|
|
151
|
+
base_url: str
|
|
152
|
+
status_code: int
|
|
153
|
+
request: Any # httpx.Request (avoid circular import at module level)
|
|
154
|
+
headers: Any # httpx.Headers
|
|
155
|
+
body: bytes
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class CopilotExchangeDiagnostics:
|
|
160
|
+
"""最近一次 Copilot token 交换的运行时诊断."""
|
|
161
|
+
|
|
162
|
+
raw_shape: str = ""
|
|
163
|
+
token_field: str = ""
|
|
164
|
+
expires_in_seconds: int = 0
|
|
165
|
+
expires_at_unix: int = 0
|
|
166
|
+
capabilities: dict[str, Any] = field(default_factory=dict)
|
|
167
|
+
updated_at_unix: int = 0
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict[str, Any]:
|
|
170
|
+
data: dict[str, Any] = {}
|
|
171
|
+
if self.raw_shape:
|
|
172
|
+
data["raw_shape"] = self.raw_shape
|
|
173
|
+
if self.token_field:
|
|
174
|
+
data["token_field"] = self.token_field
|
|
175
|
+
if self.expires_in_seconds:
|
|
176
|
+
data["expires_in_seconds"] = self.expires_in_seconds
|
|
177
|
+
if self.expires_at_unix:
|
|
178
|
+
data["ttl_seconds"] = max(self.expires_at_unix - int(__import__("time").time()), 0)
|
|
179
|
+
if self.capabilities:
|
|
180
|
+
data["capabilities"] = self.capabilities
|
|
181
|
+
if self.updated_at_unix:
|
|
182
|
+
data["updated_at"] = self.updated_at_unix
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class CopilotModelCatalog:
|
|
188
|
+
"""Copilot 模型目录缓存."""
|
|
189
|
+
|
|
190
|
+
available_models: list[str] = field(default_factory=list)
|
|
191
|
+
fetched_at_unix: int = 0
|
|
192
|
+
|
|
193
|
+
def age_seconds(self) -> int | None:
|
|
194
|
+
if not self.fetched_at_unix:
|
|
195
|
+
return None
|
|
196
|
+
return max(int(__import__("time").time()) - self.fetched_at_unix, 0)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ═══════════════════════════════════════════════════════════════
|
|
200
|
+
# 向后兼容别名(v2 移除)
|
|
201
|
+
# ═══════════════════════════════════════════════════════════════
|
|
202
|
+
|
|
203
|
+
BackendCapabilities = VendorCapabilities
|
|
204
|
+
BackendResponse = VendorResponse
|
|
205
|
+
NoCompatibleBackendError = NoCompatibleVendorError
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
# 新命名
|
|
209
|
+
"VendorCapabilities", "VendorResponse", "NoCompatibleVendorError",
|
|
210
|
+
# 向后兼容别名
|
|
211
|
+
"BackendCapabilities", "BackendResponse", "NoCompatibleBackendError",
|
|
212
|
+
# 通用类型(不变)
|
|
213
|
+
"UsageInfo", "CapabilityLossReason", "RequestCapabilities",
|
|
214
|
+
# Copilot 诊断类
|
|
215
|
+
"CopilotExchangeDiagnostics", "CopilotMisdirectedRequest", "CopilotModelCatalog",
|
|
216
|
+
# 工具函数
|
|
217
|
+
"decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response",
|
|
218
|
+
]
|
coding/proxy/pricing.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""模型定价表.
|
|
2
|
+
|
|
3
|
+
基于配置文件中的手动定价条目,按 (vendor, model_served) 计算 Cost。
|
|
4
|
+
|
|
5
|
+
``ModelPricing`` / ``Currency`` / ``CostValue`` 数据模型已迁移至 :mod:`coding.proxy.model.pricing`。
|
|
6
|
+
本文件保留 ``PricingTable`` 查询与计算逻辑,类型通过 re-export 提供。
|
|
7
|
+
|
|
8
|
+
.. deprecated::
|
|
9
|
+
未来版本将移除类型 re-export,请直接从 :mod:`coding.proxy.model.pricing` 导入。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
# noqa: F401
|
|
19
|
+
from .model.pricing import CostValue, Currency, ModelPricing
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .config.schema import ModelPricingEntry
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize(name: str) -> str:
|
|
28
|
+
"""规范化模型名称以提升匹配成功率.
|
|
29
|
+
|
|
30
|
+
规则:
|
|
31
|
+
- 去除 @版本后缀(如 @20241022)
|
|
32
|
+
- 将 `.` 替换为 `-`
|
|
33
|
+
- 转小写
|
|
34
|
+
"""
|
|
35
|
+
name = re.sub(r"@[\w.]+$", "", name)
|
|
36
|
+
return name.replace(".", "-").lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PricingTable:
|
|
40
|
+
"""基于配置文件的本地定价表,支持按 (vendor, model_served) 查询单价."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, entries: list[ModelPricingEntry]) -> None:
|
|
43
|
+
self._index: dict[tuple[str, str], ModelPricing] = {}
|
|
44
|
+
for entry in entries:
|
|
45
|
+
pricing = ModelPricing(
|
|
46
|
+
currency=Currency(entry.currency),
|
|
47
|
+
input_cost_per_token=entry.input_cost_per_mtok / 1e6,
|
|
48
|
+
output_cost_per_token=entry.output_cost_per_mtok / 1e6,
|
|
49
|
+
cache_creation_input_token_cost=entry.cache_write_cost_per_mtok / 1e6,
|
|
50
|
+
cache_read_input_token_cost=entry.cache_read_cost_per_mtok / 1e6,
|
|
51
|
+
)
|
|
52
|
+
# 精确匹配
|
|
53
|
+
self._index[(entry.vendor, entry.model)] = pricing
|
|
54
|
+
# 规范化匹配(如 "glm-4.5-air" → "glm-4-5-air")
|
|
55
|
+
norm = _normalize(entry.model)
|
|
56
|
+
if norm != entry.model:
|
|
57
|
+
self._index.setdefault((entry.vendor, norm), pricing)
|
|
58
|
+
|
|
59
|
+
if entries:
|
|
60
|
+
logger.info("定价表加载成功,共 %d 条模型配置", len(entries))
|
|
61
|
+
|
|
62
|
+
# ── 单价查询 ──────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def get_pricing(self, vendor: str, model_served: str) -> ModelPricing | None:
|
|
65
|
+
"""获取 (vendor, model_served) 对应的 ModelPricing.
|
|
66
|
+
|
|
67
|
+
查找顺序:
|
|
68
|
+
1. 精确匹配:(vendor, model_served)
|
|
69
|
+
2. 规范化匹配:(vendor, normalized(model_served))
|
|
70
|
+
"""
|
|
71
|
+
hit = self._index.get((vendor, model_served))
|
|
72
|
+
if hit is not None:
|
|
73
|
+
return hit
|
|
74
|
+
return self._index.get((vendor, _normalize(model_served)))
|
|
75
|
+
|
|
76
|
+
# ── 费用计算 ──────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def compute_cost(
|
|
79
|
+
self,
|
|
80
|
+
vendor: str,
|
|
81
|
+
model_served: str,
|
|
82
|
+
input_tokens: int,
|
|
83
|
+
output_tokens: int,
|
|
84
|
+
cache_creation_tokens: int,
|
|
85
|
+
cache_read_tokens: int,
|
|
86
|
+
) -> CostValue | None:
|
|
87
|
+
"""按单价计算总费用(含币种信息).
|
|
88
|
+
|
|
89
|
+
返回 :class:`CostValue`(携带币种),若无匹配定价返回 None。
|
|
90
|
+
"""
|
|
91
|
+
pricing = self.get_pricing(vendor, model_served)
|
|
92
|
+
if pricing is None:
|
|
93
|
+
return None
|
|
94
|
+
amount = (
|
|
95
|
+
input_tokens * pricing.input_cost_per_token
|
|
96
|
+
+ output_tokens * pricing.output_cost_per_token
|
|
97
|
+
+ cache_creation_tokens * pricing.cache_creation_input_token_cost
|
|
98
|
+
+ cache_read_tokens * pricing.cache_read_input_token_cost
|
|
99
|
+
)
|
|
100
|
+
return CostValue(amount=amount, currency=pricing.currency)
|