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