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.
Files changed (77) hide show
  1. coding/__init__.py +0 -0
  2. coding/proxy/__init__.py +3 -0
  3. coding/proxy/__main__.py +5 -0
  4. coding/proxy/auth/__init__.py +13 -0
  5. coding/proxy/auth/providers/__init__.py +6 -0
  6. coding/proxy/auth/providers/base.py +35 -0
  7. coding/proxy/auth/providers/github.py +133 -0
  8. coding/proxy/auth/providers/google.py +237 -0
  9. coding/proxy/auth/runtime.py +122 -0
  10. coding/proxy/auth/store.py +74 -0
  11. coding/proxy/cli/__init__.py +151 -0
  12. coding/proxy/cli/auth_commands.py +224 -0
  13. coding/proxy/compat/__init__.py +30 -0
  14. coding/proxy/compat/canonical.py +193 -0
  15. coding/proxy/compat/session_store.py +137 -0
  16. coding/proxy/config/__init__.py +6 -0
  17. coding/proxy/config/auth_schema.py +24 -0
  18. coding/proxy/config/loader.py +139 -0
  19. coding/proxy/config/resiliency.py +46 -0
  20. coding/proxy/config/routing.py +279 -0
  21. coding/proxy/config/schema.py +280 -0
  22. coding/proxy/config/server.py +23 -0
  23. coding/proxy/config/vendors.py +53 -0
  24. coding/proxy/convert/__init__.py +14 -0
  25. coding/proxy/convert/anthropic_to_gemini.py +352 -0
  26. coding/proxy/convert/anthropic_to_openai.py +352 -0
  27. coding/proxy/convert/gemini_sse_adapter.py +169 -0
  28. coding/proxy/convert/gemini_to_anthropic.py +98 -0
  29. coding/proxy/convert/openai_to_anthropic.py +88 -0
  30. coding/proxy/logging/__init__.py +49 -0
  31. coding/proxy/logging/db.py +308 -0
  32. coding/proxy/logging/stats.py +129 -0
  33. coding/proxy/model/__init__.py +93 -0
  34. coding/proxy/model/auth.py +32 -0
  35. coding/proxy/model/compat.py +153 -0
  36. coding/proxy/model/constants.py +21 -0
  37. coding/proxy/model/pricing.py +70 -0
  38. coding/proxy/model/token.py +64 -0
  39. coding/proxy/model/vendor.py +218 -0
  40. coding/proxy/pricing.py +100 -0
  41. coding/proxy/routing/__init__.py +47 -0
  42. coding/proxy/routing/circuit_breaker.py +152 -0
  43. coding/proxy/routing/error_classifier.py +67 -0
  44. coding/proxy/routing/executor.py +453 -0
  45. coding/proxy/routing/model_mapper.py +90 -0
  46. coding/proxy/routing/quota_guard.py +169 -0
  47. coding/proxy/routing/rate_limit.py +159 -0
  48. coding/proxy/routing/retry.py +82 -0
  49. coding/proxy/routing/router.py +84 -0
  50. coding/proxy/routing/session_manager.py +62 -0
  51. coding/proxy/routing/tier.py +171 -0
  52. coding/proxy/routing/usage_parser.py +193 -0
  53. coding/proxy/routing/usage_recorder.py +131 -0
  54. coding/proxy/server/__init__.py +1 -0
  55. coding/proxy/server/app.py +142 -0
  56. coding/proxy/server/factory.py +175 -0
  57. coding/proxy/server/request_normalizer.py +139 -0
  58. coding/proxy/server/responses.py +74 -0
  59. coding/proxy/server/routes.py +264 -0
  60. coding/proxy/streaming/__init__.py +1 -0
  61. coding/proxy/streaming/anthropic_compat.py +484 -0
  62. coding/proxy/vendors/__init__.py +29 -0
  63. coding/proxy/vendors/anthropic.py +44 -0
  64. coding/proxy/vendors/antigravity.py +328 -0
  65. coding/proxy/vendors/base.py +353 -0
  66. coding/proxy/vendors/copilot.py +702 -0
  67. coding/proxy/vendors/copilot_models.py +438 -0
  68. coding/proxy/vendors/copilot_token_manager.py +167 -0
  69. coding/proxy/vendors/copilot_urls.py +16 -0
  70. coding/proxy/vendors/mixins.py +71 -0
  71. coding/proxy/vendors/token_manager.py +128 -0
  72. coding/proxy/vendors/zhipu.py +243 -0
  73. coding_proxy-0.1.0.dist-info/METADATA +184 -0
  74. coding_proxy-0.1.0.dist-info/RECORD +77 -0
  75. coding_proxy-0.1.0.dist-info/WHEEL +4 -0
  76. coding_proxy-0.1.0.dist-info/entry_points.txt +2 -0
  77. 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
+ ]
@@ -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)