coding-proxy 0.2.1a2__py3-none-any.whl → 0.2.2__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.
@@ -216,16 +216,50 @@ async def _run_usage(
216
216
  @app.command()
217
217
  def reset(
218
218
  port: int = typer.Option(8046, "--port", "-p", help="代理服务端口"),
219
+ vendor: str | None = typer.Option(
220
+ None,
221
+ "--vendor",
222
+ "-v",
223
+ help="提升/重排序 vendor 优先级(单个或逗号分隔多个)",
224
+ ),
219
225
  ) -> None:
220
- """重置所有层级的熔断器和配额守卫(恢复使用最高优先级供应商)."""
226
+ """重置所有层级的熔断器和配额守卫.
227
+
228
+ 可通过 -v 指定运行时 N-tier 链路重排序:
229
+
230
+ \b
231
+ -v zhipu 提升 zhipu 到最高优先级
232
+ -v zhipu,anthropic 替换整个 N-tier 链路顺序
233
+ """
221
234
  import httpx
222
235
 
236
+ # 构建请求 body
237
+ json_body: dict | None = None
238
+ if vendor:
239
+ parts = [v.strip() for v in vendor.split(",") if v.strip()]
240
+ if parts:
241
+ json_body = {"vendors": parts}
242
+
223
243
  try:
224
- resp = httpx.post(f"http://127.0.0.1:{port}/api/reset", timeout=5)
244
+ resp = httpx.post(
245
+ f"http://127.0.0.1:{port}/api/reset",
246
+ json=json_body,
247
+ timeout=5,
248
+ )
225
249
  if resp.status_code == 200:
250
+ data = resp.json()
226
251
  console.print("[green]所有层级的熔断器和配额守卫已重置[/green]")
252
+ tier_order = data.get("tier_order")
253
+ if tier_order:
254
+ order_str = " → ".join(tier_order)
255
+ console.print(f"[cyan]当前链路顺序:[/] {order_str}")
227
256
  else:
228
- console.print(f"[red]重置失败: {resp.status_code}[/red]")
257
+ try:
258
+ err = resp.json()
259
+ msg = err.get("error", {}).get("message", resp.text)
260
+ except Exception:
261
+ msg = resp.text
262
+ console.print(f"[red]重置失败: {msg}[/red]")
229
263
  except httpx.ConnectError:
230
264
  console.print("[red]代理服务未运行[/red]")
231
265
 
@@ -111,7 +111,7 @@ vendors:
111
111
  # 不配置 circuit_breaker → 自动成为终端层,不触发向下故障转移
112
112
  circuit_breaker:
113
113
  failure_threshold: 3
114
- recovery_timeout_seconds: 300
114
+ recovery_timeout_seconds: 30
115
115
  success_threshold: 2
116
116
  quota_guard:
117
117
  enabled: true # 启用后按 Premium Requests 配额管理
@@ -421,6 +421,11 @@ pricing:
421
421
  input_cost_per_mtok: ¥0.80
422
422
  output_cost_per_mtok: ¥2.00
423
423
  cache_read_cost_per_mtok: ¥0.16
424
+ - vendor: zhipu
425
+ model: glm-4.7 # 待区分长短上下文定价
426
+ input_cost_per_mtok: ¥2.00
427
+ output_cost_per_mtok: ¥8.00
428
+ cache_read_cost_per_mtok: ¥0.40
424
429
  - vendor: zhipu
425
430
  model: glm-5v-turbo # 待区分长短上下文定价
426
431
  input_cost_per_mtok: ¥5.00
@@ -1,6 +1,6 @@
1
1
  """日志模块.
2
2
 
3
- 提供 uvicorn 兼容的 dictConfig 构建、JSON 结构化格式化器、
3
+ 提供 uvicorn 兼容的 dictConfig 构建、文件日志字符串格式化器、
4
4
  以及 gzip 压缩轮转支持。
5
5
  """
6
6
 
@@ -12,7 +12,7 @@ import logging.handlers
12
12
  import os
13
13
  from pathlib import Path
14
14
 
15
- from .formatters import JsonFormatter
15
+ from .formatters import FileFormatter
16
16
 
17
17
  # ── 常量 ────────────────────────────────────────────────────────
18
18
 
@@ -83,7 +83,7 @@ def build_log_config(
83
83
 
84
84
  双写行为:
85
85
  - 控制台:人类可读格式,级别由 ``level`` 参数控制(handler 级别过滤)
86
- - 文件:JSON 结构化格式,固定 DEBUG 级别(捕获所有日志)
86
+ - 文件:字符串格式(与控制台风格一致),固定 DEBUG 级别(捕获所有日志)
87
87
  - 当 ``file_path`` 为 ``None`` 或空字符串时,退化为纯控制台模式(向后兼容)
88
88
  """
89
89
  config: dict = {
@@ -118,7 +118,11 @@ def build_log_config(
118
118
  },
119
119
  "loggers": {
120
120
  "uvicorn": {"handlers": ["default"], "level": level, "propagate": False},
121
- "uvicorn.error": {"level": level},
121
+ "uvicorn.error": {
122
+ "handlers": ["default"],
123
+ "level": level,
124
+ "propagate": False,
125
+ },
122
126
  "uvicorn.access": {
123
127
  "handlers": ["access"],
124
128
  "level": "INFO",
@@ -138,16 +142,16 @@ def build_log_config(
138
142
  log_file = Path(file_path)
139
143
  log_file.parent.mkdir(parents=True, exist_ok=True)
140
144
 
141
- # 注入 JSON formatter
142
- config["formatters"]["json"] = {
143
- "()": "coding.proxy.logging.formatters.JsonFormatter",
145
+ # 注入文件日志字符串 formatter
146
+ config["formatters"]["file_fmt"] = {
147
+ "()": "coding.proxy.logging.formatters.FileFormatter",
144
148
  }
145
149
 
146
150
  # 注入 RotatingFileHandler(gzip 压缩轮转)
147
151
  # 使用工厂函数(而非 class + namer/rotator kwargs),
148
152
  # 因为 dictConfig 不支持将 namer/rotator 作为构造参数传递
149
153
  config["handlers"]["file"] = {
150
- "formatter": "json",
154
+ "formatter": "file_fmt",
151
155
  "()": "coding.proxy.logging._create_rotating_file_handler",
152
156
  "filename": str(log_file.resolve()),
153
157
  "maxBytes": max_bytes,
@@ -182,7 +186,7 @@ def build_log_config(
182
186
 
183
187
  __all__ = [
184
188
  "build_log_config",
185
- "JsonFormatter",
189
+ "FileFormatter",
186
190
  "_gzip_namer",
187
191
  "_gzip_rotator",
188
192
  ]
@@ -1,49 +1,27 @@
1
- """结构化日志格式化器(JSON 输出).
1
+ """文件日志格式化器(字符串输出).
2
2
 
3
- 为文件日志提供机器可读的 JSON 格式输出,
4
- 与控制台的人类可读格式形成正交双写。
3
+ 为文件日志提供人类可读的字符串格式输出,
4
+ 与控制台输出风格一致,便于人工阅读和 grep 检索。
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import json
10
9
  import logging
11
- from datetime import UTC, datetime
12
10
 
13
11
 
14
- class JsonFormatter(logging.Formatter):
15
- """将 LogRecord 格式化为单行 JSON 字符串.
12
+ class FileFormatter(logging.Formatter):
13
+ """将 LogRecord 格式化为单行可读字符串.
16
14
 
17
- 输出字段:
18
- - ``timestamp``: ISO 8601 UTC 时间戳
19
- - ``level``: 日志级别名称(DEBUG/INFO/WARNING/ERROR)
20
- - ``logger``: logger 名称(如 ``coding.proxy.routing.executor``)
21
- - ``message``: 格式化后的日志消息
22
- - ``exception``: 异常堆栈(仅当存在时)
15
+ 输出格式:``2026-04-11 16:51:13 INFO ModelCall: vendor=zhipu ...``
23
16
 
24
17
  设计要点:
25
- - 使用 ``ensure_ascii=False`` 支持中文日志内容
26
- - 异常信息(exc_info)序列化为 ``exception`` 字段
27
- - 时间戳统一使用 UTC ISO 格式,便于跨时区聚合分析
28
- - ``sort_keys=True`` 保证输出确定性,便于日志聚合工具处理
18
+ - 时间戳使用 ``yyyy-MM-dd HH:mm:ss`` 格式(与控制台一致)
19
+ - 日志级别左对齐 5 字符宽度,保证多行对齐美观
20
+ - ANSI 颜色码(文件输出不需要终端转义)
29
21
  """
30
22
 
31
- def format(self, record: logging.LogRecord) -> str:
32
- """将 LogRecord 序列化为 JSON 行."""
33
- message = record.getMessage()
34
-
35
- exception: str | None = None
36
- if record.exc_info and record.exc_info[0] is not None:
37
- exception = self.formatException(record.exc_info)
38
-
39
- log_entry: dict[str, object] = {
40
- "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
41
- "level": record.levelname,
42
- "logger": record.name,
43
- "message": message,
44
- }
45
-
46
- if exception:
47
- log_entry["exception"] = exception
48
-
49
- return json.dumps(log_entry, ensure_ascii=False, sort_keys=True)
23
+ def __init__(self) -> None:
24
+ super().__init__(
25
+ fmt="%(asctime)s %(levelname)-5s %(message)s",
26
+ datefmt="%Y-%m-%d %H:%M:%S",
27
+ )
@@ -32,11 +32,14 @@ class CircuitBreaker:
32
32
  recovery_timeout_seconds: int = 300,
33
33
  success_threshold: int = 2,
34
34
  max_recovery_seconds: int = 3600,
35
+ *,
36
+ vendor_name: str = "",
35
37
  ) -> None:
36
38
  self._failure_threshold = failure_threshold
37
39
  self._recovery_timeout = recovery_timeout_seconds
38
40
  self._success_threshold = success_threshold
39
41
  self._max_recovery = max_recovery_seconds
42
+ self._vendor_label = f" [{vendor_name}]" if vendor_name else ""
40
43
 
41
44
  self._state = CircuitState.CLOSED
42
45
  self._failure_count = 0
@@ -65,7 +68,13 @@ class CircuitBreaker:
65
68
  self._success_count += 1
66
69
  if self._success_count >= self._success_threshold:
67
70
  self._transition_to(CircuitState.CLOSED)
68
- logger.info("Circuit breaker: HALF_OPEN → CLOSED (recovered)")
71
+ logger.info(
72
+ "Circuit breaker%s: HALF_OPEN → CLOSED "
73
+ "(recovered, %d/%d consecutive successes)",
74
+ self._vendor_label,
75
+ self._success_count,
76
+ self._success_threshold,
77
+ )
69
78
  elif self._state == CircuitState.CLOSED:
70
79
  # 正常状态下成功,无需操作
71
80
  pass
@@ -94,7 +103,10 @@ class CircuitBreaker:
94
103
  self._transition_to(CircuitState.OPEN)
95
104
  self._backoff_recovery(hint_seconds=retry_after_seconds)
96
105
  logger.warning(
97
- "Circuit breaker: HALF_OPEN → OPEN (recovery failed, next retry in %ds)",
106
+ "Circuit breaker%s: HALF_OPEN → OPEN "
107
+ "(recovery probe failed, backoff %ds → next retry in %ds)",
108
+ self._vendor_label,
109
+ self._current_recovery,
98
110
  self._current_recovery,
99
111
  )
100
112
  elif self._state == CircuitState.CLOSED:
@@ -117,14 +129,17 @@ class CircuitBreaker:
117
129
  )
118
130
  if force_open:
119
131
  logger.warning(
120
- "Circuit breaker: CLOSED → OPEN "
121
- "(forced, rate-limited, next retry in %ds)",
132
+ "Circuit breaker%s: CLOSED → OPEN "
133
+ "(forced, rate-limited, retry-after=%ss → next retry in %ds)",
134
+ self._vendor_label,
135
+ retry_after_seconds or "N/A",
122
136
  self._current_recovery,
123
137
  )
124
138
  else:
125
139
  logger.warning(
126
- "Circuit breaker: CLOSED → OPEN "
140
+ "Circuit breaker%s: CLOSED → OPEN "
127
141
  "(%d consecutive failures, next retry in %ds)",
142
+ self._vendor_label,
128
143
  self._failure_count,
129
144
  self._current_recovery,
130
145
  )
@@ -134,7 +149,9 @@ class CircuitBreaker:
134
149
  with self._lock:
135
150
  self._transition_to(CircuitState.CLOSED)
136
151
  self._current_recovery = self._recovery_timeout
137
- logger.info("Circuit breaker: manually reset to CLOSED")
152
+ logger.info(
153
+ "Circuit breaker%s: manually reset to CLOSED", self._vendor_label
154
+ )
138
155
 
139
156
  def get_info(self) -> dict:
140
157
  """获取熔断器状态信息."""
@@ -157,7 +174,13 @@ class CircuitBreaker:
157
174
  elapsed = time.monotonic() - self._last_failure_time
158
175
  if elapsed >= self._current_recovery:
159
176
  self._transition_to(CircuitState.HALF_OPEN)
160
- logger.info("Circuit breaker: OPEN → HALF_OPEN (recovery timeout)")
177
+ elapsed_s = int(elapsed)
178
+ logger.info(
179
+ "Circuit breaker%s: OPEN → HALF_OPEN (recovery timeout, waited %ds/%ds)",
180
+ self._vendor_label,
181
+ elapsed_s,
182
+ self._current_recovery,
183
+ )
161
184
 
162
185
  def _transition_to(self, new_state: CircuitState) -> None:
163
186
  self._state = new_state
@@ -48,9 +48,13 @@ logger = logging.getLogger(__name__)
48
48
 
49
49
 
50
50
  def _log_http_error_detail(
51
- tier_name: str, exc: Exception, *, is_stream: bool = False
51
+ tier_name: str,
52
+ exc: Exception,
53
+ *,
54
+ is_stream: bool = False,
55
+ tier: VendorTier | None = None,
52
56
  ) -> None:
53
- """记录 HTTP 错误的详细信息(状态码 / 响应体摘要 / 异常类型).
57
+ """记录 HTTP 错误的详细信息(状态码 / 响应体摘要 / 异常类型 / 熔断器快照).
54
58
 
55
59
  替代原先单行 ``logger.warning("Tier %s stream failed: %s", ...)``,
56
60
  在非 200 响应时输出更丰富的诊断上下文,便于跟踪上游故障根因。
@@ -78,6 +82,14 @@ def _log_http_error_detail(
78
82
  detail_parts.append(f" error_msg={err.get('message', 'N/A')[:200]}")
79
83
  else:
80
84
  detail_parts.append(f" message={str(exc)[:300]}")
85
+ # 熔断器状态快照
86
+ if tier and tier.circuit_breaker:
87
+ cb = tier.circuit_breaker
88
+ cb_info = cb.get_info()
89
+ detail_parts.append(
90
+ f" circuit_breaker={cb_info['state']} "
91
+ f"(failures={cb_info['failure_count']}/{cb._failure_threshold})"
92
+ )
81
93
  logger.warning("\n".join(detail_parts))
82
94
 
83
95
 
@@ -268,6 +280,12 @@ class _RouteExecutor:
268
280
  duration = int((time.monotonic() - start) * 1000)
269
281
  model = body.get("model", "unknown")
270
282
  model_served = usage.get("model_served") or tier.vendor.map_model(model)
283
+ if failed_tier_name is not None:
284
+ logger.info(
285
+ "Tier %s stream succeeded (took over from failed tier: %s)",
286
+ tier.name,
287
+ failed_tier_name,
288
+ )
271
289
  self._recorder.log_model_call(
272
290
  vendor=tier.name,
273
291
  model_requested=model,
@@ -310,7 +328,7 @@ class _RouteExecutor:
310
328
  httpx.ConnectError,
311
329
  httpx.ReadError,
312
330
  ) as exc:
313
- _log_http_error_detail(tier.name, exc, is_stream=True)
331
+ _log_http_error_detail(tier.name, exc, is_stream=True, tier=tier)
314
332
  (
315
333
  should_continue,
316
334
  failed_tier_name,
@@ -325,6 +343,7 @@ class _RouteExecutor:
325
343
  request_body=body,
326
344
  )
327
345
  if should_continue:
346
+ self._log_failover_transition(tier, exc, self._tiers, i)
328
347
  continue
329
348
  if is_last:
330
349
  raise
@@ -399,6 +418,12 @@ class _RouteExecutor:
399
418
  duration = int((time.monotonic() - start) * 1000)
400
419
  model = body.get("model", "unknown")
401
420
  model_served = resp.model_served or tier.vendor.map_model(model)
421
+ if failed_tier_name is not None:
422
+ logger.info(
423
+ "Tier %s message succeeded (took over from failed tier: %s)",
424
+ tier.name,
425
+ failed_tier_name,
426
+ )
402
427
  self._recorder.log_model_call(
403
428
  vendor=tier.name,
404
429
  model_requested=model,
@@ -469,10 +494,15 @@ class _RouteExecutor:
469
494
  rate_limit_deadline=compute_rate_limit_deadline(rl_info),
470
495
  )
471
496
  if not is_last:
497
+ next_tier = (
498
+ self._tiers[i + 1] if i + 1 < len(self._tiers) else None
499
+ )
500
+ next_info = f" → next: {next_tier.name}" if next_tier else ""
472
501
  logger.warning(
473
- "Tier %s error %d, failing over",
502
+ "Tier %s error %d, failing over%s",
474
503
  tier.name,
475
504
  resp.status_code,
505
+ next_info,
476
506
  )
477
507
  failed_tier_name = tier.name
478
508
  continue
@@ -513,7 +543,7 @@ class _RouteExecutor:
513
543
  continue
514
544
 
515
545
  except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc:
516
- _log_http_error_detail(tier.name, exc, is_stream=False)
546
+ _log_http_error_detail(tier.name, exc, is_stream=False, tier=tier)
517
547
  tier.record_failure()
518
548
  failed_tier_name = tier.name
519
549
  if is_last:
@@ -694,6 +724,30 @@ class _RouteExecutor:
694
724
 
695
725
  return False, tier.name, exc
696
726
 
727
+ @staticmethod
728
+ def _log_failover_transition(
729
+ current_tier: VendorTier,
730
+ exc: Exception,
731
+ tiers: list[VendorTier],
732
+ current_index: int,
733
+ ) -> None:
734
+ """记录 vendor 轮转摘要日志(谁 → 谁,原因)."""
735
+ next_tier = tiers[current_index + 1] if current_index + 1 < len(tiers) else None
736
+ if next_tier is None:
737
+ return
738
+
739
+ # 提取错误摘要
740
+ reason = type(exc).__name__
741
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None:
742
+ reason = f"HTTP {exc.response.status_code}"
743
+
744
+ logger.info(
745
+ "Failover: %s → %s (reason: %s)",
746
+ current_tier.name,
747
+ next_tier.name,
748
+ reason,
749
+ )
750
+
697
751
  @staticmethod
698
752
  def _is_cap_error(resp: VendorResponse) -> bool:
699
753
  """判断是否为订阅用量上限错误."""
@@ -68,6 +68,66 @@ class RequestRouter:
68
68
  """当前活跃供应商名称(由 Executor 在成功响应时写入)."""
69
69
  return self._active_vendor_name
70
70
 
71
+ # ── 运行时 N-tier 链路重排序 ─────────────────────────────
72
+
73
+ def get_vendor_names(self) -> list[str]:
74
+ """返回当前 tiers 的供应商名称列表(按优先级顺序)."""
75
+ return [t.name for t in self._tiers]
76
+
77
+ def reorder_tiers(self, vendor_names: list[str]) -> None:
78
+ """原地重排序 N-tier 链路.
79
+
80
+ 使用切片赋值保持列表引用同一性,使 ``_RouteExecutor`` 立即可见。
81
+
82
+ Args:
83
+ vendor_names: 新的供应商名称顺序(必须包含所有当前 tier)。
84
+
85
+ Raises:
86
+ ValueError: 名称不存在、有重复、或未覆盖所有 tier。
87
+ """
88
+ name_to_tier = {t.name: t for t in self._tiers}
89
+ current_names = set(name_to_tier)
90
+
91
+ # 校验:重复
92
+ if len(vendor_names) != len(set(vendor_names)):
93
+ seen: set[str] = set()
94
+ dups = [n for n in vendor_names if n in seen or seen.add(n)] # type: ignore[func-returns-value]
95
+ raise ValueError(f"vendor 名称重复: {', '.join(dups)}")
96
+
97
+ # 校验:名称存在性
98
+ unknown = [n for n in vendor_names if n not in current_names]
99
+ if unknown:
100
+ raise ValueError(
101
+ f"未知 vendor: {', '.join(unknown)}; "
102
+ f"可用: {', '.join(sorted(current_names))}"
103
+ )
104
+
105
+ # 校验:全量覆盖
106
+ provided = set(vendor_names)
107
+ if provided != current_names:
108
+ missing = current_names - provided
109
+ raise ValueError(f"缺少 vendor: {', '.join(sorted(missing))}")
110
+
111
+ self._tiers[:] = [name_to_tier[n] for n in vendor_names]
112
+
113
+ def promote_vendor(self, vendor_name: str) -> None:
114
+ """将指定 vendor 提升至最高优先级,其余保持相对顺序.
115
+
116
+ Args:
117
+ vendor_name: 要提升的供应商名称。
118
+
119
+ Raises:
120
+ ValueError: 名称不存在。
121
+ """
122
+ current_names = self.get_vendor_names()
123
+ if vendor_name not in current_names:
124
+ available = sorted(t.name for t in self._tiers)
125
+ raise ValueError(
126
+ f"未知 vendor: {vendor_name}; 可用: {', '.join(available)}"
127
+ )
128
+ new_order = [vendor_name] + [n for n in current_names if n != vendor_name]
129
+ self.reorder_tiers(new_order)
130
+
71
131
  # ── 公开路由接口(委托给 _RouteExecutor)───────────────
72
132
 
73
133
  async def route_stream(
@@ -155,11 +155,33 @@ class VendorTier:
155
155
  if not is_probe_scenario:
156
156
  return cb_allows and qg_allows and wqg_allows
157
157
 
158
+ # 构建探测上下文摘要
159
+ probe_context_parts: list[str] = []
160
+ if self.circuit_breaker:
161
+ cb_info = self.circuit_breaker.get_info()
162
+ probe_context_parts.append(
163
+ f"circuit_breaker={cb_info['state']}, "
164
+ f"failures={cb_info['failure_count']}"
165
+ )
166
+ if self._rate_limit_deadline > 0:
167
+ waited = int(time.monotonic() - self._rate_limit_deadline)
168
+ probe_context_parts.append(f"rate_limit_waited={waited}s")
169
+ probe_context = (
170
+ " (" + ", ".join(probe_context_parts) + ")" if probe_context_parts else ""
171
+ )
172
+
158
173
  # ── 第二层: Health Check 门控 ──
159
- logger.info("Tier %s: probe scenario, running health check", self.name)
174
+ logger.info(
175
+ "Tier %s: probe scenario%s, running health check",
176
+ self.name,
177
+ probe_context,
178
+ )
160
179
  healthy = await self.vendor.check_health()
161
180
  if not healthy:
162
- logger.warning("Tier %s: health check failed, staying degraded", self.name)
181
+ logger.warning(
182
+ "Tier %s: health check failed, staying degraded",
183
+ self.name,
184
+ )
163
185
  self.record_failure()
164
186
  return False
165
187
 
@@ -106,7 +106,9 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
106
106
  vendor_cfg, config.failover, mapper, token_store
107
107
  )
108
108
  cb = (
109
- _build_circuit_breaker(vendor_cfg.circuit_breaker)
109
+ _build_circuit_breaker(
110
+ vendor_cfg.circuit_breaker, vendor_name=vendor_cfg.vendor
111
+ )
110
112
  if vendor_cfg.circuit_breaker
111
113
  else None
112
114
  )
@@ -91,13 +91,16 @@ def _find_copilot_vendor(router: Any) -> CopilotVendor | None:
91
91
  return None
92
92
 
93
93
 
94
- def _build_circuit_breaker(cfg: CircuitBreakerConfig) -> CircuitBreaker:
94
+ def _build_circuit_breaker(
95
+ cfg: CircuitBreakerConfig, *, vendor_name: str = ""
96
+ ) -> CircuitBreaker:
95
97
  """从配置构建熔断器实例."""
96
98
  return CircuitBreaker(
97
99
  failure_threshold=cfg.failure_threshold,
98
100
  recovery_timeout_seconds=cfg.recovery_timeout_seconds,
99
101
  success_threshold=cfg.success_threshold,
100
102
  max_recovery_seconds=cfg.max_recovery_seconds,
103
+ vendor_name=vendor_name,
101
104
  )
102
105
 
103
106
 
@@ -261,7 +261,40 @@ def register_admin_routes(app: Any, router: Any) -> None:
261
261
  """注册管理操作路由(重置等)."""
262
262
 
263
263
  @app.post("/api/reset")
264
- async def reset_circuit() -> dict:
264
+ async def reset_circuit(request: Request) -> Response:
265
+ """重置所有层级的熔断器/配额守卫/rate limit.
266
+
267
+ 可选 JSON body ``{"vendors": ["v1", "v2", ...]}`` 支持运行时重排序:
268
+ - 单个 vendor → 提升至最高优先级,其余保持相对顺序
269
+ - 多个 vendor → 替换整个 N-tier 链路顺序(需覆盖所有 vendor)
270
+ """
271
+ # 解析可选 body
272
+ vendor_names: list[str] | None = None
273
+ try:
274
+ body = await request.json()
275
+ if isinstance(body, dict):
276
+ raw = body.get("vendors")
277
+ if isinstance(raw, list) and raw:
278
+ vendor_names = [str(v) for v in raw]
279
+ except Exception:
280
+ # 无 body 或非 JSON → 仅 reset(向后兼容)
281
+ pass
282
+
283
+ # 重排序(如果指定)
284
+ if vendor_names is not None:
285
+ try:
286
+ if len(vendor_names) == 1:
287
+ router.promote_vendor(vendor_names[0])
288
+ else:
289
+ router.reorder_tiers(vendor_names)
290
+ except ValueError as exc:
291
+ return json_error_response(
292
+ 400,
293
+ error_type="invalid_request_error",
294
+ message=str(exc),
295
+ )
296
+
297
+ # 全量 reset
265
298
  for tier in router.tiers:
266
299
  if tier.circuit_breaker:
267
300
  tier.circuit_breaker.reset()
@@ -270,7 +303,16 @@ def register_admin_routes(app: Any, router: Any) -> None:
270
303
  if tier.weekly_quota_guard:
271
304
  tier.weekly_quota_guard.reset()
272
305
  tier.reset_rate_limit()
273
- return {"status": "ok"}
306
+
307
+ result: dict[str, Any] = {"status": "ok"}
308
+ if vendor_names is not None:
309
+ result["tier_order"] = router.get_vendor_names()
310
+
311
+ return Response(
312
+ content=json.dumps(result, ensure_ascii=False).encode(),
313
+ status_code=200,
314
+ media_type="application/json",
315
+ )
274
316
 
275
317
 
276
318
  def register_reauth_routes(app: Any, reauth_coordinator: Any) -> None:
@@ -37,6 +37,9 @@ _V1INTERNAL_USER_AGENT = (
37
37
  "Antigravity/4.1.31 (Macintosh; Intel Mac OS X 10_15_7) "
38
38
  "Chrome/132.0.6834.160 Electron/39.2.3"
39
39
  )
40
+ # Cloud Resource Manager API(用于自动发现 GCP project_id)
41
+ _CRM_PROJECTS_URL = "https://cloudresourcemanager.googleapis.com/v1/projects"
42
+ _V1INTERNAL_BASE_URL = "https://cloudcode-pa.googleapis.com/v1internal"
40
43
 
41
44
 
42
45
  # ── Google OAuth2 Token 管理器(原 antigravity_token_manager.py) ──
@@ -148,13 +151,119 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
148
151
  self._project_id: str = config.project_id
149
152
  self._session_id: str = uuid.uuid4().hex[:16]
150
153
  self._message_count: int = 0
154
+ # project_id 自动发现状态
155
+ self._project_id_discovered: str = ""
156
+ self._project_discovery_attempted: bool = False
151
157
 
152
158
  def get_name(self) -> str:
153
159
  return "antigravity"
154
160
 
155
161
  def _is_v1internal_mode(self) -> bool:
156
162
  """检测是否启用 v1internal 协议模式(与 Antigravity-Manager 对齐)."""
157
- return bool(self._project_id) and "v1internal" in self._base_url
163
+ return bool(self._effective_project_id) and "v1internal" in self._base_url
164
+
165
+ @property
166
+ def _effective_project_id(self) -> str:
167
+ """返回有效的 project_id:显式配置优先,否则使用自动发现的值."""
168
+ return self._project_id or self._project_id_discovered
169
+
170
+ async def _discover_project_id(self, access_token: str) -> str:
171
+ """通过 Cloud Resource Manager API 自动发现用户的 GCP project_id.
172
+
173
+ 利用已有的 cloud-platform OAuth scope 调用 CRM API 列出用户有权限的项目,
174
+ 选择合适的 project_id 后自动切换至 v1internal 模式。
175
+
176
+ Args:
177
+ access_token: 当前有效的 Google OAuth access_token
178
+
179
+ Returns:
180
+ 发现到的 project_id;失败返回空字符串
181
+ """
182
+ if self._project_discovery_attempted:
183
+ return self._project_id_discovered
184
+
185
+ # 已手动配置则跳过
186
+ if self._project_id:
187
+ self._project_discovery_attempted = True
188
+ return ""
189
+
190
+ self._project_discovery_attempted = True
191
+ client = self._get_client()
192
+ try:
193
+ response = await client.get(
194
+ _CRM_PROJECTS_URL,
195
+ headers={"authorization": f"Bearer {access_token}"},
196
+ )
197
+ response.raise_for_status()
198
+
199
+ data = response.json()
200
+ projects = data.get("projects", [])
201
+
202
+ if not projects:
203
+ logger.warning(
204
+ "GCP 项目自动发现完成但未找到任何项目,"
205
+ "回退至标准 GLA 模式。请手动配置 project_id 或确认账号已关联 GCP 项目。"
206
+ )
207
+ return ""
208
+
209
+ # 选择策略:优先 ACTIVE 状态的项目
210
+ selected = None
211
+ for p in projects:
212
+ if p.get("lifecycleState", "") == "ACTIVE":
213
+ selected = p
214
+ break
215
+ if selected is None:
216
+ for p in projects:
217
+ if p.get("lifecycleState", "") != "DELETE_REQUESTED":
218
+ selected = p
219
+ break
220
+ if selected is None:
221
+ selected = projects[0]
222
+
223
+ project_id = selected.get("projectId", "")
224
+ if not project_id:
225
+ logger.warning(
226
+ "GCP 项目 '%s' 缺少 projectId 字段,跳过。",
227
+ selected.get("name", "unknown"),
228
+ )
229
+ return ""
230
+
231
+ # 发现成功:原子性切换到 v1internal 模式
232
+ self._base_url = _V1INTERNAL_BASE_URL
233
+ self._project_id_discovered = project_id
234
+
235
+ # 重建 HTTP 客户端(base_url 是初始化参数)
236
+ if self._client is not None and not self._client.is_closed:
237
+ await self._client.aclose()
238
+ self._client = None
239
+
240
+ logger.info(
241
+ "GCP 项目自动发现成功: project_id=%s, name=%s, "
242
+ "已自动切换至 v1internal 协议模式",
243
+ project_id,
244
+ selected.get("name", "unknown"),
245
+ )
246
+ return project_id
247
+
248
+ except httpx.HTTPStatusError as exc:
249
+ logger.error(
250
+ "GCP 项目自动发现 API 错误 (HTTP %d),回退至标准 GLA 模式。%s",
251
+ exc.response.status_code,
252
+ exc,
253
+ )
254
+ return ""
255
+ except (httpx.TimeoutException, httpx.ConnectError) as exc:
256
+ logger.warning(
257
+ "GCP 项目自动发现网络异常,回退至标准 GLA 模式。%s",
258
+ exc,
259
+ )
260
+ return ""
261
+ except Exception as exc:
262
+ logger.error(
263
+ "GCP 项目自动发现未知异常,回退至标准 GLA 模式。%s",
264
+ exc,
265
+ )
266
+ return ""
158
267
 
159
268
  def get_capabilities(self) -> VendorCapabilities:
160
269
  return VendorCapabilities(
@@ -230,6 +339,19 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
230
339
  self._last_request_adaptations = converted.adaptations
231
340
  token = await self._token_manager.get_token()
232
341
 
342
+ # 懒加载:未配置 project_id 时自动发现并切换 v1internal 模式
343
+ if not self._project_id and not self._project_discovery_attempted:
344
+ discovered = await self._discover_project_id(token)
345
+ if discovered:
346
+ logger.info(
347
+ "已自动启用 v1internal 协议模式(project_id=%s)", discovered
348
+ )
349
+ else:
350
+ logger.info(
351
+ "无法自动发现 GCP project_id,继续使用标准 GLA 模式。"
352
+ "如需启用 v1internal 协议,请在配置中手动指定 project_id。"
353
+ )
354
+
233
355
  if self._is_v1internal_mode():
234
356
  return self._prepare_v1internal_request(gemini_body, resolved_model, token)
235
357
 
@@ -259,7 +381,7 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
259
381
  """
260
382
  self._message_count += 1
261
383
  envelope = {
262
- "project": self._project_id,
384
+ "project": self._effective_project_id,
263
385
  "requestId": f"agent/antigravity/{self._session_id}/{self._message_count}",
264
386
  "request": gemini_body,
265
387
  "model": resolved_model,
@@ -277,7 +399,7 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
277
399
  "_prepare_v1internal_request: model=%s -> %s, project=%s, requestId=%s",
278
400
  resolved_model,
279
401
  resolved_model,
280
- self._project_id,
402
+ self._effective_project_id,
281
403
  envelope["requestId"],
282
404
  )
283
405
  return envelope, new_headers
@@ -308,6 +430,15 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
308
430
  result["resolved_model"] = self._last_resolved_model
309
431
  if self._last_model_resolution_reason:
310
432
  result["model_resolution_reason"] = self._last_model_resolution_reason
433
+ # project_id 发现诊断
434
+ result["project_id_source"] = (
435
+ "configured"
436
+ if self._project_id
437
+ else ("discovered" if self._project_id_discovered else "none")
438
+ )
439
+ if self._project_id_discovered:
440
+ result["discovered_project_id"] = self._project_id_discovered
441
+ result["is_v1internal_mode"] = self._is_v1internal_mode()
311
442
  return result
312
443
 
313
444
  async def send_message(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.2.1a2
3
+ Version: 0.2.2
4
4
  Summary: A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao...
5
5
  Project-URL: Source Code, https://github.com/ThreeFish-AI/coding-proxy
6
6
  Project-URL: User Guide, https://github.com/ThreeFish-AI/coding-proxy/blob/master/docs/user-guide.md
@@ -56,7 +56,7 @@ When you're deeply immersed in your coding "zone" with **Claude Code** (or any A
56
56
 
57
57
  ## 🌟 Core Features
58
58
 
59
- - **⛓️ N-tier Chained Failover**: Automatically downgrades from official Claude Plans, gracefully falling back to GitHub Copilot, then Google Antigravity, with Zhipu GLM acting as the ultimate safety net.
59
+ - **⛓️ N-tier Chained Failover**: Autonomous descending sequence, supporting Claude's official plans, as well as Coding Plans from GitHub Copilot, Z AI, MiniMax, Alibaba Qwen, Xiaomi, Kimi, Doubao, etc.
60
60
  - **🛡️ Smart Resilience & Quota Guardians**: Every single vendor node comes fully armed with an independent **Circuit Breaker** and **Quota Guard** to proactively dodge avalanches without breaking a sweat.
61
61
  - **👻 Phantom-like Transparency**: **100% transparent** to the client! No code tweaks required. Overwrite `ANTHROPIC_BASE_URL` with a single line, and you're good to go.
62
62
  - **🔄 Universal Alchemy (Formats & Models)**: Native support for two-way request/streaming (SSE) translations between Anthropic ←→ Gemini. Plus, auto/DIY model name mapping (e.g., effortlessly morphing `claude-*` into `glm-*`).
@@ -9,7 +9,7 @@ coding/proxy/auth/providers/__init__.py,sha256=hnGL_aSHcMxiZHK6HKPWD0_tWSPcwuxwQ
9
9
  coding/proxy/auth/providers/base.py,sha256=TOtCi2b81-UBdEoWMv7HbYSmRxAjGSvnF1yBxazqai0,1092
10
10
  coding/proxy/auth/providers/github.py,sha256=xH6yygAzt8vI-MXV8EHl2tg4RcOmLaLBns93PBCyVG8,4835
11
11
  coding/proxy/auth/providers/google.py,sha256=MhbM5PXmgwQBgWCMDkhK_7GYyvS-IdPV4ATxKYH6WXM,8402
12
- coding/proxy/cli/__init__.py,sha256=glGVFdTj8fMLYSSWNtFGU4u9D2AoYBTLNgaSkjyQEnM,7911
12
+ coding/proxy/cli/__init__.py,sha256=wkec3lIOglEOMN44ssGPzixCJ2hiTZ2wYL4_JtBc4Nw,8911
13
13
  coding/proxy/cli/auth_commands.py,sha256=tN5jWJo8sJP8jgoN4WH5iTl9rhi4xWPLMptgeTHIFpE,9212
14
14
  coding/proxy/cli/banner.py,sha256=gat7QGpfocL0wiAw3-MSUsCviyc59Sf-XswOp7Pg6LI,1694
15
15
  coding/proxy/compat/__init__.py,sha256=-msRszArsk2YJJtOeaidYDd1tAQEzeE91LvCi1CyMis,721
@@ -17,7 +17,7 @@ coding/proxy/compat/canonical.py,sha256=-zcuEwZ402xeH3C545RmuYRkT2HDuvFloyFydDv8
17
17
  coding/proxy/compat/session_store.py,sha256=B9IFjjQJnHMg1244m__jG9gnqGWi26-JyEtwopEri6Q,5244
18
18
  coding/proxy/config/__init__.py,sha256=hzgU5noJGecjj13UY38cC_p6jpWO3GO7okSW-A2XkJ0,127
19
19
  coding/proxy/config/auth_schema.py,sha256=LYrJQU_fgW-6AoQdjXt4-MgPJjXjv9HrghlCcZwotnA,696
20
- coding/proxy/config/config.default.yaml,sha256=UjtM6b4FNAJvilCm2fgjNvN5hPfDHt0EYKLlvL_0UDU,16404
20
+ coding/proxy/config/config.default.yaml,sha256=WT5wK3T9h3ODyz7ydicieC4PGZTHq-419FV__dxdj4M,16575
21
21
  coding/proxy/config/loader.py,sha256=1J_RBJgjuC8RwB2mwewLMS7vy9WEhUqttZG2igeDm-w,8984
22
22
  coding/proxy/config/resiliency.py,sha256=GnzY-LoyfFqXFM1l6xEru418v-cKlv97-HM0noGPdks,1308
23
23
  coding/proxy/config/routing.py,sha256=aJMhfCRyoZIvemM3Q2_KV9rpWxUsFnoY0ZdCr4TwSs8,11765
@@ -30,9 +30,9 @@ coding/proxy/convert/anthropic_to_openai.py,sha256=crE8gi780Tr-dI58hce75BiaKzlh_
30
30
  coding/proxy/convert/gemini_sse_adapter.py,sha256=b7zQ9wBBn1bbG46WpLrAYOl0ExpyMTzpTorxoTadWaM,7395
31
31
  coding/proxy/convert/gemini_to_anthropic.py,sha256=EE_rUsLmWTsF2QSYWqIYzsjyk1mKXFp2sziqj_S2W5U,3446
32
32
  coding/proxy/convert/openai_to_anthropic.py,sha256=8rg8NZs_1up27MixrXRJAfmSiYrOkh_lkAJuytDGZow,3694
33
- coding/proxy/logging/__init__.py,sha256=VQ0GTb95cER0yIIrwfPTGEbRdUW4d2pnTv4NClmuG_o,6591
33
+ coding/proxy/logging/__init__.py,sha256=6zEh2CELMJ5aZvM6PNCOFlZ55De8xoC6QNyMAa9WeCA,6755
34
34
  coding/proxy/logging/db.py,sha256=uARNaaAXWxemwfyybp660ujpi6UikNtFokG0pzXYwR8,19058
35
- coding/proxy/logging/formatters.py,sha256=au9fsFvm6x5JLUyx-UVKq24yOcGOJhxgyTf9QBWcfns,1677
35
+ coding/proxy/logging/formatters.py,sha256=8LDqiNAxGcfn9fsHqZzdK9TKGqOGNAOls75B1byBjnM,823
36
36
  coding/proxy/logging/stats.py,sha256=JlhJC4a2RmuCjbaseSUONScbcgn41KNR_sNsZ0OScbw,9203
37
37
  coding/proxy/model/__init__.py,sha256=E8E59yabP-CF5GIwHE4OEGPLiJj3yTLjFWmCorkQTxc,3947
38
38
  coding/proxy/model/auth.py,sha256=NiiIPdLdxPtuHNrsGoxFASUXwJU5pkDuVRorQe_1Z-Q,934
@@ -42,30 +42,30 @@ coding/proxy/model/pricing.py,sha256=KHYsLQJJkEEuAeX8iB182znfpfSPTf9KI1hPchphqnc
42
42
  coding/proxy/model/token.py,sha256=yCh-66_nFDAk7wKbYy-9ymdgae3SaJ47-fkYfYLO3Oo,1818
43
43
  coding/proxy/model/vendor.py,sha256=Yg4AYN7tgjHaGWRGEccCs3AdO8vBc-KxIelnqRowpyo,7898
44
44
  coding/proxy/routing/__init__.py,sha256=2Tpc6MQ5Oy_w-YlEWQVDSgcSgiHp3OwKOABIjj5kfHA,1595
45
- coding/proxy/routing/circuit_breaker.py,sha256=fKodIYxJ-3hhil2rEjsGqvyHTspliN_eS8QyL3BFI8s,7189
45
+ coding/proxy/routing/circuit_breaker.py,sha256=hOyoY_RWB5dTVbipyuiWfAelnva3cZRLbXrSmSn8KTs,8096
46
46
  coding/proxy/routing/error_classifier.py,sha256=oDxp69sCb-pdfe1hWce29A9tsN3jAbhqfl2fRECBtCo,3747
47
- coding/proxy/routing/executor.py,sha256=xQw3rtHd00n3BBgUHzqAli1GUoFNOxc0odKEaq-Dw7A,28206
47
+ coding/proxy/routing/executor.py,sha256=vnXmmaWw0w49HhKsNine37Hnye04NSZ0vc9ghMzqLdQ,30268
48
48
  coding/proxy/routing/model_mapper.py,sha256=b72CGRdusLAwoq7p8-8TWAv9-Zv8BozssiZC8XnNTx4,3574
49
49
  coding/proxy/routing/quota_guard.py,sha256=TRJs3mjdRMc_u0HCw-hZn--osha_m11dEtAUxOO2P5A,6806
50
50
  coding/proxy/routing/rate_limit.py,sha256=i2cCqtbmXrP6wjRUTWXLP4DdYwVj0C4o59QdYGkPQj0,5122
51
51
  coding/proxy/routing/retry.py,sha256=KEYVToBkBOtXdqY7He70RrtVWFqIpnOQf2VVksdLrlk,2386
52
- coding/proxy/routing/router.py,sha256=bXaglxuT-5qYzrSMk7ZE4mpQynP4Z6F2pm9hnCbdG90,3440
52
+ coding/proxy/routing/router.py,sha256=x5x6cjL5BOnRXZZuzK3GCnNtaMVjw75xw2OQ-DoO9_s,5830
53
53
  coding/proxy/routing/session_manager.py,sha256=kpEPCo67CdsELX71hoe6G7sb2dcda7gTNL0FtpgRK7I,2950
54
- coding/proxy/routing/tier.py,sha256=U12Mw0eTE1AO5RuHNpoOuXDdujz_Hkx_iyIrqkZbtyA,7024
54
+ coding/proxy/routing/tier.py,sha256=OBrJiMxfLCxXvZfARD5oNmuhpYASKnyrW9sOCHLdQ1g,7777
55
55
  coding/proxy/routing/usage_parser.py,sha256=j4G0ArFduQ2D4Yeuad94DVlwdc-JvSh7SJLVzc-hLcs,8480
56
56
  coding/proxy/routing/usage_recorder.py,sha256=pObOrX2yIITTiyojl1fJcqO0yWWpbP4KqsJvFdmlt04,6273
57
57
  coding/proxy/server/__init__.py,sha256=KeH7mEu36v9v27m3VgeSxSeFz9sLTJrxS_EVATJ7Vks,20
58
- coding/proxy/server/app.py,sha256=ITCbtDrweNMp-l4_uNN-8wQ33BXpjvCk6p2dn8E0424,5538
59
- coding/proxy/server/factory.py,sha256=I4asjFw_1ET49DC-xkCpfV7oyfSv4G6xUB_Z5N_SHY8,9920
58
+ coding/proxy/server/app.py,sha256=kRGgb772dZu8200LPnn7Nt0IU5oagcbBf_Vc5ynxxzE,5599
59
+ coding/proxy/server/factory.py,sha256=w8VFvxoogw9K9sO8MlT6bIP7xM7mR6sCohrZle9y_Gg,9985
60
60
  coding/proxy/server/request_normalizer.py,sha256=F_mgxxuA3i7cN1CEWlto7Mm9VT_QG2QNspiUWeYVBpM,10511
61
61
  coding/proxy/server/responses.py,sha256=i0ugnLRNOdRYGHEWxkwsxR35ChmdMQsSaD8AjRluTn4,2167
62
- coding/proxy/server/routes.py,sha256=QeEsYczjvPfVhOrED2qe1V6CpwVBZbQHs8dP0OGdGXQ,11734
62
+ coding/proxy/server/routes.py,sha256=_CkeOEcnMe21RcLZ-raxgknUbdclV-Uf9f_B2Xz9_a8,13325
63
63
  coding/proxy/streaming/__init__.py,sha256=0al5TC-zJt8Bz0CibTmd8WPza-Cs-qcuZWvT2fKPqHI,23
64
64
  coding/proxy/streaming/anthropic_compat.py,sha256=cycYiJ-glN88OmfLTR7FroHRethONlEcK0PT78KqGbA,21202
65
65
  coding/proxy/vendors/__init__.py,sha256=fehSpQ2-kUlZY6N8cNMGVJYMXspQmi3V4e0FgC4Ay4k,1011
66
66
  coding/proxy/vendors/alibaba.py,sha256=vYhvk8hZEeOpqUrtGT-uaK89y6uolrDUAT2BvTD_IQY,876
67
67
  coding/proxy/vendors/anthropic.py,sha256=KA_fvOxagGBggH2pc3l9Zoyl_XYUyRdfR8bI-eVj_e4,6328
68
- coding/proxy/vendors/antigravity.py,sha256=byFa64uUmPJv0CnoseF6dEkgYhPC_Jlrw3h0H43vkZ8,16164
68
+ coding/proxy/vendors/antigravity.py,sha256=sz2-TSIUe9YDVen7A4d9K6lmhyh4jGWN3dppfabSJg0,21431
69
69
  coding/proxy/vendors/base.py,sha256=vrAaI_4uLtzaFcPWtBStVMHU5AgkCWTNLEgVeoHHgLE,14781
70
70
  coding/proxy/vendors/copilot.py,sha256=qAFTO2xMqnawaFlvZS2a9-7f_UEkfODIFfDlgOBiQl4,29590
71
71
  coding/proxy/vendors/copilot_models.py,sha256=UckIQYA4qLyNwEA09koQGBOZ65Nxhuo3ytUuDNqtzRU,15514
@@ -79,8 +79,8 @@ coding/proxy/vendors/native_anthropic.py,sha256=SxtM71PDci0gqLqiwCrFnT410SnSoD7F
79
79
  coding/proxy/vendors/token_manager.py,sha256=s10t4Com0jNnKGkPyJ_HpG5SjHrCEJvfArEOAaPKA_k,4189
80
80
  coding/proxy/vendors/xiaomi.py,sha256=E-GcmJBZh7GOtDFonxZmlf0hKRhrlrXzL0IxHFRYcRo,860
81
81
  coding/proxy/vendors/zhipu.py,sha256=3j_rqNFu1CX-B5ugtrL6Y1OeWSy9yiqsVa9Bi1ssaAA,1062
82
- coding_proxy-0.2.1a2.dist-info/METADATA,sha256=-xhbcbCthQVTX7CN4d3XQTPLRWhpLN2TH130IKN3Awo,10821
83
- coding_proxy-0.2.1a2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
84
- coding_proxy-0.2.1a2.dist-info/entry_points.txt,sha256=moIVzt5ho0Wk9B47LOo2SEAbhzuDDHWi-EfM30U0XBg,54
85
- coding_proxy-0.2.1a2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
86
- coding_proxy-0.2.1a2.dist-info/RECORD,,
82
+ coding_proxy-0.2.2.dist-info/METADATA,sha256=4eYTzmULjk3sHv87lB4NnGv4RQ2I_VNADAeLVJGB9mQ,10817
83
+ coding_proxy-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
84
+ coding_proxy-0.2.2.dist-info/entry_points.txt,sha256=moIVzt5ho0Wk9B47LOo2SEAbhzuDDHWi-EfM30U0XBg,54
85
+ coding_proxy-0.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
86
+ coding_proxy-0.2.2.dist-info/RECORD,,