@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,336 @@
1
+ """Anthropic thinking and tool-choice compatibility policies.
2
+
3
+ The policy owns protocol-level message transformations. Provider discovery and
4
+ logging remain injected ports so this module does not depend on the runtime
5
+ facade or on concrete provider adapters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ THINKING_BLOCK_TYPES: tuple[str, ...] = ("thinking", "redacted_thinking")
17
+
18
+
19
+ def message_content_blocks(message: dict[str, Any]) -> list[Any]:
20
+ content = message.get("content")
21
+ if isinstance(content, list):
22
+ return content
23
+ if isinstance(content, str):
24
+ return [{"type": "text", "text": content}]
25
+ return []
26
+
27
+
28
+ def thinking_requested(body: dict[str, Any]) -> bool:
29
+ thinking = body.get("thinking")
30
+ if isinstance(thinking, dict):
31
+ thinking_type = str(thinking.get("type") or "").strip().lower()
32
+ return thinking_type not in ("", "disabled", "none", "off", "false")
33
+ return bool(thinking)
34
+
35
+
36
+ def thinking_block_count(body: dict[str, Any]) -> int:
37
+ return sum(
38
+ 1
39
+ for message in body.get("messages") or []
40
+ if isinstance(message, dict)
41
+ for block in message_content_blocks(message)
42
+ if isinstance(block, dict) and block.get("type") in THINKING_BLOCK_TYPES
43
+ )
44
+
45
+
46
+ def tool_continuation_block_count(body: dict[str, Any]) -> int:
47
+ count = 0
48
+ for message in body.get("messages") or []:
49
+ if not isinstance(message, dict):
50
+ continue
51
+ role = message.get("role")
52
+ for block in message_content_blocks(message):
53
+ if not isinstance(block, dict):
54
+ continue
55
+ block_type = block.get("type")
56
+ if (role == "assistant" and block_type == "tool_use") or (
57
+ role == "user" and block_type == "tool_result"
58
+ ):
59
+ count += 1
60
+ return count
61
+
62
+
63
+ def assistant_history_count(body: dict[str, Any]) -> int:
64
+ return sum(
65
+ 1
66
+ for message in body.get("messages") or []
67
+ if isinstance(message, dict) and message.get("role") == "assistant"
68
+ )
69
+
70
+
71
+ def strip_thinking_blocks(body: dict[str, Any]) -> dict[str, Any]:
72
+ messages = body.get("messages")
73
+ if not isinstance(messages, list):
74
+ return body
75
+ changed = False
76
+ projected: list[Any] = []
77
+ for message in messages:
78
+ content = message.get("content") if isinstance(message, dict) else None
79
+ if not isinstance(content, list):
80
+ projected.append(message)
81
+ continue
82
+ filtered = [
83
+ block
84
+ for block in content
85
+ if not (
86
+ isinstance(block, dict)
87
+ and block.get("type") in THINKING_BLOCK_TYPES
88
+ )
89
+ ]
90
+ if len(filtered) == len(content):
91
+ projected.append(message)
92
+ continue
93
+ projected.append({**message, "content": filtered})
94
+ changed = True
95
+ return {**body, "messages": projected} if changed else body
96
+
97
+
98
+ def has_synthetic_tool_use(body: dict[str, Any]) -> bool:
99
+ return any(
100
+ str(block.get("id") or "").startswith("toolu_ciel_runtime_")
101
+ for message in body.get("messages") or []
102
+ if isinstance(message, dict) and message.get("role") == "assistant"
103
+ for block in message_content_blocks(message)
104
+ if isinstance(block, dict) and block.get("type") == "tool_use"
105
+ )
106
+
107
+
108
+ def copy_thinking_blocks(blocks: Any) -> list[dict[str, Any]]:
109
+ if not isinstance(blocks, list):
110
+ return []
111
+ return [
112
+ dict(block)
113
+ for block in blocks
114
+ if isinstance(block, dict) and block.get("type") in THINKING_BLOCK_TYPES
115
+ ]
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class ThinkingPolicyPorts:
120
+ preserves_contract: Callable[[str, dict[str, Any]], bool]
121
+ reasoning_passback_enabled: Callable[[str, dict[str, Any], dict[str, Any]], bool]
122
+ suggestion_mode: Callable[[dict[str, Any]], bool]
123
+ log: Callable[[str, str], None]
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class ToolChoicePorts:
128
+ normalize: Callable[[str, dict[str, Any], dict[str, Any], Any], Any]
129
+ supports: Callable[[str, dict[str, Any], dict[str, Any]], bool]
130
+ log: Callable[[str, str], None]
131
+
132
+
133
+ class SuppressedThinkingRepository:
134
+ """Bounded in-memory repository for passback blocks hidden from clients."""
135
+
136
+ def __init__(
137
+ self,
138
+ records: list[dict[str, Any]],
139
+ capacity: Callable[[], int],
140
+ ) -> None:
141
+ self._records = records
142
+ self._capacity = capacity
143
+
144
+ def clear(self) -> None:
145
+ self._records.clear()
146
+
147
+ def remember(self, provider: str, model: str, blocks: list[Any]) -> int:
148
+ copied = copy_thinking_blocks(blocks)
149
+ if not copied:
150
+ return 0
151
+ self._records.append(
152
+ {
153
+ "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
154
+ "provider": provider,
155
+ "model": model,
156
+ "blocks": copied,
157
+ }
158
+ )
159
+ capacity = max(1, self._capacity())
160
+ del self._records[:-capacity]
161
+ return len(copied)
162
+
163
+ def recent_for(self, provider: str, limit: int) -> list[dict[str, Any]]:
164
+ if limit <= 0:
165
+ return []
166
+ matches = [record for record in self._records if record.get("provider") == provider]
167
+ return matches[-limit:]
168
+
169
+ def __len__(self) -> int:
170
+ return len(self._records)
171
+
172
+
173
+ class AnthropicThinkingPolicy:
174
+ def __init__(
175
+ self,
176
+ ports: ThinkingPolicyPorts,
177
+ repository: SuppressedThinkingRepository,
178
+ ) -> None:
179
+ self._ports = ports
180
+ self._repository = repository
181
+
182
+ def normalize_request(
183
+ self, provider: str, config: dict[str, Any], body: dict[str, Any]
184
+ ) -> dict[str, Any]:
185
+ requested = thinking_requested(body)
186
+ block_count = thinking_block_count(body)
187
+ if (not requested and block_count <= 0) or self._ports.preserves_contract(
188
+ provider, config
189
+ ):
190
+ return body
191
+ if self._ports.reasoning_passback_enabled(provider, config, body):
192
+ if not requested:
193
+ return body
194
+ projected = dict(body)
195
+ projected.pop("thinking", None)
196
+ self._ports.log(
197
+ "INFO",
198
+ "removed top-level Anthropic thinking request but preserved thinking blocks "
199
+ f"for OpenAI-chat reasoning passback provider={provider} "
200
+ f"thinking_blocks={block_count}",
201
+ )
202
+ return projected
203
+ projected = dict(strip_thinking_blocks(body))
204
+ projected.pop("thinking", None)
205
+ self._ports.log(
206
+ "WARN",
207
+ "removed Anthropic thinking request and thinking content blocks for "
208
+ f"non-Anthropic provider provider={provider} "
209
+ f"synthetic_tool={has_synthetic_tool_use(body)} "
210
+ f"continuation_blocks={tool_continuation_block_count(body)} "
211
+ f"assistant_history={assistant_history_count(body)} "
212
+ f"thinking_blocks={block_count}",
213
+ )
214
+ return projected
215
+
216
+ def normalize_response(
217
+ self,
218
+ provider: str,
219
+ config: dict[str, Any],
220
+ message: dict[str, Any],
221
+ model: str | None = None,
222
+ ) -> dict[str, Any]:
223
+ if self._ports.preserves_contract(provider, config):
224
+ return message
225
+ content = message.get("content")
226
+ if not isinstance(content, list):
227
+ return message
228
+ filtered = [
229
+ block
230
+ for block in content
231
+ if not (
232
+ isinstance(block, dict)
233
+ and block.get("type") in THINKING_BLOCK_TYPES
234
+ )
235
+ ]
236
+ if len(filtered) == len(content):
237
+ return message
238
+ self.remember(provider, str(model or message.get("model") or ""), content)
239
+ self._ports.log(
240
+ "WARN",
241
+ "removed Anthropic thinking response blocks for non-Anthropic provider "
242
+ f"provider={provider} thinking_blocks={len(content) - len(filtered)}",
243
+ )
244
+ return {**message, "content": filtered or [{"type": "text", "text": ""}]}
245
+
246
+ def remember(self, provider: str, model: str, blocks: list[Any]) -> None:
247
+ copied = self._repository.remember(provider, model, blocks)
248
+ if copied:
249
+ self._ports.log(
250
+ "WARN",
251
+ "stored suppressed Anthropic thinking passback blocks "
252
+ f"provider={provider} model={model} blocks={copied} "
253
+ f"cache={len(self._repository)}",
254
+ )
255
+
256
+ def rehydrate(
257
+ self, provider: str, config: dict[str, Any], body: dict[str, Any]
258
+ ) -> dict[str, Any]:
259
+ if (
260
+ self._ports.preserves_contract(provider, config)
261
+ or self._ports.suggestion_mode(body)
262
+ or not len(self._repository)
263
+ ):
264
+ return body
265
+ messages = body.get("messages")
266
+ if not isinstance(messages, list):
267
+ return body
268
+ indices = [
269
+ index
270
+ for index, message in enumerate(messages)
271
+ if isinstance(message, dict)
272
+ and message.get("role") == "assistant"
273
+ and not thinking_block_count({"messages": [message]})
274
+ and (
275
+ (isinstance(message.get("content"), list) and bool(message["content"]))
276
+ or (isinstance(message.get("content"), str) and bool(message["content"]))
277
+ )
278
+ ]
279
+ records = self._repository.recent_for(provider, len(indices))
280
+ if not records:
281
+ return body
282
+ selected = indices[-len(records) :]
283
+ projected = list(messages)
284
+ inserted = 0
285
+ for index, record in zip(selected, records):
286
+ message = projected[index]
287
+ blocks = copy_thinking_blocks(record.get("blocks"))
288
+ if not isinstance(message, dict) or not blocks:
289
+ continue
290
+ content = message.get("content")
291
+ if isinstance(content, list):
292
+ new_content = blocks + list(content)
293
+ elif isinstance(content, str):
294
+ new_content = blocks + [{"type": "text", "text": content}]
295
+ else:
296
+ continue
297
+ projected[index] = {**message, "content": new_content}
298
+ inserted += len(blocks)
299
+ if not inserted:
300
+ return body
301
+ self._ports.log(
302
+ "WARN",
303
+ "rehydrated suppressed Anthropic thinking passback blocks for upstream "
304
+ f"provider={provider} blocks={inserted} "
305
+ f"assistant_messages={len(selected)} cache={len(self._repository)}",
306
+ )
307
+ return {**body, "messages": projected}
308
+
309
+
310
+ def normalize_tool_choice(
311
+ provider: str,
312
+ config: dict[str, Any],
313
+ body: dict[str, Any],
314
+ ports: ToolChoicePorts,
315
+ ) -> dict[str, Any]:
316
+ tool_choice = body.get("tool_choice")
317
+ if tool_choice is None:
318
+ return body
319
+ normalized = ports.normalize(provider, config, body, tool_choice)
320
+ if normalized != tool_choice:
321
+ ports.log(
322
+ "WARN",
323
+ f"normalized unsupported forced tool_choice for provider={provider}: "
324
+ f"model={body.get('model')} tool_choice={tool_choice}",
325
+ )
326
+ return {**body, "tool_choice": normalized}
327
+ if ports.supports(provider, config, body):
328
+ return body
329
+ projected = dict(body)
330
+ removed = projected.pop("tool_choice", None)
331
+ ports.log(
332
+ "WARN",
333
+ f"removed unsupported tool_choice for {provider}: "
334
+ f"model={body.get('model')} tool_choice={removed}",
335
+ )
336
+ return projected
@@ -0,0 +1,315 @@
1
+ """Anthropic Messages projections for chat-style provider protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from typing import Any, Callable
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ChatProjectionText:
12
+ system_messages: Callable[..., list[dict[str, Any]]]
13
+ execution_reminder: Callable[..., dict[str, str]]
14
+ state_messages: Callable[..., list[dict[str, Any]]]
15
+ content_to_text: Callable[..., str]
16
+ compact_text: Callable[..., str]
17
+ skip_message: Callable[..., bool]
18
+ attachment_only: Callable[..., bool]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ChatProjectionTools:
23
+ collect_result_context: Callable[..., tuple[dict[str, str], set[str]]]
24
+ plan_mode_active: Callable[..., bool]
25
+ input_for_prompt: Callable[..., str]
26
+ persisted_output: Callable[..., bool]
27
+ truncate_for_prompt: Callable[..., str]
28
+ canonical_signature: Callable[..., str]
29
+ format_result: Callable[..., tuple[str, str]]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ChatProjectionPolicy:
34
+ thinking_block_types: frozenset[str] | set[str] | tuple[str, ...]
35
+ tool_result_limit: int
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ChatProjectionServices:
40
+ text: ChatProjectionText
41
+ tools: ChatProjectionTools
42
+ policy: ChatProjectionPolicy
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class OpenAiHistoryServices:
47
+ log: Callable[[str, str], None]
48
+
49
+
50
+ def anthropic_messages_to_ollama(body: dict[str, Any], *, services: ChatProjectionServices) -> list[dict[str, Any]]:
51
+ text = services.text
52
+ tools = services.tools
53
+ messages = text.system_messages(body.get("system"))
54
+ messages.append(text.execution_reminder())
55
+ messages.extend(text.state_messages(body))
56
+ prior_tool_results, latest_noop_result_ids = tools.collect_result_context(body)
57
+ in_plan_mode = tools.plan_mode_active(body)
58
+ tool_names_by_id: dict[str, str] = {}
59
+ tool_inputs_by_id: dict[str, Any] = {}
60
+ for message in body.get("messages", []) or []:
61
+ if not isinstance(message, dict) or text.attachment_only(message) or text.skip_message(message):
62
+ continue
63
+ role = message.get("role", "user")
64
+ content = message.get("content", "")
65
+ if role == "user" and isinstance(content, list):
66
+ text_blocks: list[Any] = []
67
+ tool_blocks: list[dict[str, Any]] = []
68
+ for block in content:
69
+ if isinstance(block, dict) and block.get("type") == "tool_result":
70
+ tool_blocks.append(block)
71
+ else:
72
+ text_blocks.append(block)
73
+ user_text = text.content_to_text(text_blocks)
74
+ if user_text:
75
+ messages.append({"role": "user", "content": text.compact_text(user_text)})
76
+ for block in tool_blocks:
77
+ tool_use_id = str(block.get("tool_use_id") or "")
78
+ tool_name = tool_names_by_id.get(tool_use_id, "tool")
79
+ tool_input = tool_inputs_by_id.get(tool_use_id)
80
+ tool_input_text = tools.input_for_prompt(tool_input)
81
+ raw_result_text = text.content_to_text(block.get("content", ""))
82
+ if tools.persisted_output(raw_result_text):
83
+ result_text = raw_result_text
84
+ tool_text = raw_result_text
85
+ tool_summary = ""
86
+ else:
87
+ result_text = tools.truncate_for_prompt(raw_result_text, services.policy.tool_result_limit)
88
+ signature = tools.canonical_signature(tool_name, tool_input)
89
+ tool_text, tool_summary = tools.format_result(
90
+ tool_name=tool_name,
91
+ tool_input_text=tool_input_text,
92
+ result_text=result_text,
93
+ is_error=bool(block.get("is_error")),
94
+ prior_success_text=prior_tool_results.get(signature, ""),
95
+ include_prior_success=tool_use_id in latest_noop_result_ids,
96
+ in_plan_mode=in_plan_mode,
97
+ )
98
+ if not block.get("is_error") and tool_name == "TaskList":
99
+ tool_summary = (
100
+ f"The task list is current:\n{result_text}\n\n"
101
+ "If any task is in_progress and the user's request is not finished, your next response "
102
+ "must call a concrete work tool such as Write, Edit, Read, or Bash. Do not respond with "
103
+ "another progress announcement like 'I will write the files now'. If everything is "
104
+ "actually complete, provide the final answer."
105
+ )
106
+ messages.append({"role": "tool", "tool_name": tool_name, "content": tool_text})
107
+ if tool_summary:
108
+ messages.append({"role": "user", "content": tool_summary})
109
+ continue
110
+ message_text = text.content_to_text(content)
111
+ out: dict[str, Any] = {"role": role, "content": text.compact_text(message_text)}
112
+ if role == "assistant" and isinstance(content, list):
113
+ calls = []
114
+ for block in content:
115
+ if isinstance(block, dict) and block.get("type") == "tool_use":
116
+ name = str(block.get("name") or "tool")
117
+ tool_id = str(block.get("id") or "")
118
+ if tool_id:
119
+ tool_names_by_id[tool_id] = name
120
+ tool_inputs_by_id[tool_id] = block.get("input") or {}
121
+ calls.append({"function": {"name": name, "arguments": block.get("input") or {}}})
122
+ if calls:
123
+ out["tool_calls"] = calls
124
+ messages.append(out)
125
+ return messages
126
+
127
+
128
+ def anthropic_messages_to_openai(
129
+ body: dict[str, Any],
130
+ reasoning_passback: bool = False,
131
+ *,
132
+ services: ChatProjectionServices,
133
+ ) -> list[dict[str, Any]]:
134
+ text = services.text
135
+ tools = services.tools
136
+ messages = text.system_messages(body.get("system"))
137
+ messages.append(text.execution_reminder())
138
+ messages.extend(text.state_messages(body))
139
+ prior_tool_results, latest_noop_result_ids = tools.collect_result_context(body)
140
+ in_plan_mode = tools.plan_mode_active(body)
141
+ tool_names_by_id: dict[str, str] = {}
142
+ tool_inputs_by_id: dict[str, Any] = {}
143
+ for message in body.get("messages", []) or []:
144
+ if not isinstance(message, dict) or text.attachment_only(message) or text.skip_message(message):
145
+ continue
146
+ role = message.get("role", "user")
147
+ content = message.get("content", "")
148
+ if role == "assistant" and isinstance(content, list):
149
+ text_blocks: list[Any] = []
150
+ tool_calls: list[dict[str, Any]] = []
151
+ reasoning_seen = False
152
+ reasoning_parts: list[str] = []
153
+ for block in content:
154
+ if isinstance(block, dict) and block.get("type") in services.policy.thinking_block_types:
155
+ reasoning_seen = True
156
+ if block.get("type") == "thinking":
157
+ reasoning_parts.append(str(block.get("thinking") or ""))
158
+ continue
159
+ if isinstance(block, dict) and block.get("type") == "tool_use":
160
+ tool_id = str(block.get("id") or f"call_{len(tool_calls) + 1}")
161
+ name = str(block.get("name") or "tool")
162
+ tool_input = block.get("input") if isinstance(block.get("input"), dict) else {}
163
+ tool_names_by_id[tool_id] = name
164
+ tool_inputs_by_id[tool_id] = tool_input
165
+ tool_calls.append({
166
+ "id": tool_id,
167
+ "type": "function",
168
+ "function": {"name": name, "arguments": json.dumps(tool_input, ensure_ascii=False)},
169
+ })
170
+ else:
171
+ text_blocks.append(block)
172
+ out: dict[str, Any] = {"role": "assistant", "content": text.compact_text(text.content_to_text(text_blocks))}
173
+ if reasoning_seen or reasoning_passback:
174
+ out["reasoning_content"] = "\n".join(reasoning_parts)
175
+ if tool_calls:
176
+ out["tool_calls"] = tool_calls
177
+ messages.append(out)
178
+ continue
179
+ if role == "user" and isinstance(content, list):
180
+ text_blocks = []
181
+ for block in content:
182
+ if isinstance(block, dict) and block.get("type") == "tool_result":
183
+ tool_id = str(block.get("tool_use_id") or "call_tool")
184
+ raw_result_text = text.content_to_text(block.get("content", ""))
185
+ if tool_id not in tool_names_by_id:
186
+ result_text = raw_result_text if tools.persisted_output(raw_result_text) else tools.truncate_for_prompt(raw_result_text, services.policy.tool_result_limit)
187
+ if tools.persisted_output(result_text):
188
+ text_blocks.append({"type": "text", "text": result_text})
189
+ else:
190
+ text_blocks.append({"type": "text", "text": f"Historical tool result without a retained assistant tool call ({tool_id}):\n{result_text}"})
191
+ continue
192
+ tool_name = tool_names_by_id.get(tool_id, "tool")
193
+ tool_input = tool_inputs_by_id.get(tool_id)
194
+ if tools.persisted_output(raw_result_text):
195
+ tool_text = raw_result_text
196
+ else:
197
+ result_text = tools.truncate_for_prompt(raw_result_text, services.policy.tool_result_limit)
198
+ tool_text, _ = tools.format_result(
199
+ tool_name=tool_name,
200
+ tool_input_text=tools.input_for_prompt(tool_input),
201
+ result_text=result_text,
202
+ is_error=bool(block.get("is_error")),
203
+ prior_success_text=prior_tool_results.get(tools.canonical_signature(tool_name, tool_input), ""),
204
+ include_prior_success=tool_id in latest_noop_result_ids,
205
+ in_plan_mode=in_plan_mode,
206
+ )
207
+ messages.append({"role": "tool", "tool_call_id": tool_id, "id": tool_id, "content": tool_text})
208
+ else:
209
+ text_blocks.append(block)
210
+ user_text = text.content_to_text(text_blocks)
211
+ if user_text:
212
+ messages.append({"role": "user", "content": text.compact_text(user_text)})
213
+ continue
214
+ out = {"role": role, "content": text.compact_text(text.content_to_text(content))}
215
+ if role == "assistant" and reasoning_passback:
216
+ out["reasoning_content"] = ""
217
+ messages.append(out)
218
+ return messages
219
+
220
+
221
+ def missing_openai_tool_result_message(tool_call: dict[str, Any]) -> dict[str, Any]:
222
+ tool_id = str(tool_call.get("id") or "call_tool")
223
+ function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {}
224
+ name = str(function.get("name") or "tool")
225
+ return {
226
+ "role": "tool",
227
+ "tool_call_id": tool_id,
228
+ "id": tool_id,
229
+ "content": (
230
+ f"Tool result for historical tool call `{name}` was not present in the retained "
231
+ "Claude Code transcript. Treat this as missing historical context, not as a "
232
+ "successful tool execution."
233
+ ),
234
+ }
235
+
236
+
237
+ def orphan_openai_tool_message_to_user(message: dict[str, Any]) -> dict[str, str]:
238
+ tool_id = str(message.get("tool_call_id") or message.get("id") or "unknown")
239
+ content = str(message.get("content") or "")
240
+ return {
241
+ "role": "user",
242
+ "content": (
243
+ f"Historical tool message without a retained assistant tool call ({tool_id}):\n"
244
+ f"{content}"
245
+ ),
246
+ }
247
+
248
+
249
+ def repair_openai_tool_call_adjacency(
250
+ messages: list[dict[str, Any]],
251
+ services: OpenAiHistoryServices,
252
+ ) -> list[dict[str, Any]]:
253
+ """Repair OpenAI's immediate tool-result adjacency invariant."""
254
+ repaired: list[dict[str, Any]] = []
255
+ missing_count = 0
256
+ orphan_count = 0
257
+ index = 0
258
+ while index < len(messages):
259
+ message = messages[index]
260
+ tool_calls = message.get("tool_calls") if isinstance(message, dict) else None
261
+ if not (message.get("role") == "assistant" and isinstance(tool_calls, list) and tool_calls):
262
+ if message.get("role") == "tool":
263
+ repaired.append(orphan_openai_tool_message_to_user(message))
264
+ orphan_count += 1
265
+ else:
266
+ repaired.append(message)
267
+ index += 1
268
+ continue
269
+ repaired.append(message)
270
+ index += 1
271
+ immediate_tools: list[dict[str, Any]] = []
272
+ while (
273
+ index < len(messages)
274
+ and isinstance(messages[index], dict)
275
+ and messages[index].get("role") == "tool"
276
+ ):
277
+ immediate_tools.append(messages[index])
278
+ index += 1
279
+ by_id: dict[str, list[dict[str, Any]]] = {}
280
+ for tool_message in immediate_tools:
281
+ tool_id = str(tool_message.get("tool_call_id") or tool_message.get("id") or "")
282
+ by_id.setdefault(tool_id, []).append(tool_message)
283
+ required_ids: list[str] = []
284
+ for call in tool_calls:
285
+ if not isinstance(call, dict):
286
+ continue
287
+ tool_id = str(call.get("id") or "")
288
+ if not tool_id:
289
+ continue
290
+ required_ids.append(tool_id)
291
+ matches = by_id.get(tool_id) or []
292
+ if matches:
293
+ repaired.append(matches.pop(0))
294
+ else:
295
+ repaired.append(missing_openai_tool_result_message(call))
296
+ missing_count += 1
297
+ required = set(required_ids)
298
+ for tool_message in immediate_tools:
299
+ tool_id = str(tool_message.get("tool_call_id") or tool_message.get("id") or "")
300
+ if tool_id in required:
301
+ remaining = by_id.get(tool_id) or []
302
+ if tool_message in remaining:
303
+ remaining.remove(tool_message)
304
+ repaired.append(orphan_openai_tool_message_to_user(tool_message))
305
+ orphan_count += 1
306
+ else:
307
+ repaired.append(orphan_openai_tool_message_to_user(tool_message))
308
+ orphan_count += 1
309
+ if missing_count or orphan_count:
310
+ services.log(
311
+ "WARN",
312
+ f"openai_tool_call_adjacency_repaired missing_tool_results={missing_count} "
313
+ f"orphan_tool_messages={orphan_count}",
314
+ )
315
+ return repaired