@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,1955 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import queue
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from http.server import BaseHTTPRequestHandler
11
+ from typing import Any, Callable, Iterable
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class AnthropicStreamIO:
16
+ ANTHROPIC_THINKING_BLOCK_TYPES: Any
17
+ VisibleToolCallArtifactFilter: type[Any]
18
+ _find_pseudo_xml_tool_start: Callable[..., Any]
19
+ _split_word_buffer: Callable[..., Any]
20
+ mark_pending_channel_delivery_failed: Callable[..., Any]
21
+ mark_pending_channel_delivery_success: Callable[..., Any]
22
+ remember_suppressed_thinking_passback: Callable[..., Any]
23
+ router_client_connection_closed: Callable[..., Any]
24
+ router_log: Callable[..., Any]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class AnthropicToolProjection:
29
+ _is_mcp_notification_wait_tool: Callable[..., Any]
30
+ _remember_channel_injected_tool_use: Callable[..., Any]
31
+ _validate_and_fix_tool_input: Callable[..., Any]
32
+ append_tool_call_log: Callable[..., Any]
33
+ cap_mcp_notification_wait_tool_input: Callable[..., Any]
34
+ infer_tool_name_from_args: Callable[..., Any]
35
+ normalize_tool_arguments: Callable[..., Any]
36
+ parse_pseudo_tool_calls: Callable[..., Any]
37
+ plan_mode_tool_name_for_emit: Callable[..., Any]
38
+ resolve_emitted_tool_name: Callable[..., Any]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class AnthropicToolPolicy:
43
+ should_drop_duplicate_side_effect_tool_call: Callable[..., Any]
44
+ should_drop_emitted_tool_call: Callable[..., Any]
45
+ should_repair_anthropic_passthrough_tool_input: Callable[..., Any]
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class AnthropicConversationContext:
50
+ backfill_exit_plan_mode_allowed_prompts: Callable[..., Any]
51
+ body_ultracode_runtime_enabled: Callable[..., Any]
52
+ empty_end_turn_notice_for_body: Callable[..., Any]
53
+ has_tool: Callable[..., Any]
54
+ latest_user_intent_message_index: Callable[..., Any]
55
+ latest_user_is_claude_code_suggestion_mode: Callable[..., Any]
56
+ latest_user_tool_result_names: Callable[..., Any]
57
+ recent_synthetic_tasklist_count: Callable[..., Any]
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class AnthropicContinuationPolicy:
62
+ should_auto_continue_choice_question_with_tasklist: Callable[..., Any]
63
+ should_auto_exit_plan_mode: Callable[..., Any]
64
+ should_keep_work_alive_with_tasklist: Callable[..., Any]
65
+ should_recover_empty_end_turn_with_tasklist: Callable[..., Any]
66
+ should_synthesize_tasklist_for_provider: Callable[..., Any]
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class AnthropicStreamServices:
71
+ io: AnthropicStreamIO
72
+ tool_projection: AnthropicToolProjection
73
+ tool_policy: AnthropicToolPolicy
74
+ conversation: AnthropicConversationContext
75
+ continuation: AnthropicContinuationPolicy
76
+
77
+
78
+ def rebatch_anthropic_sse_text(
79
+ handler: BaseHTTPRequestHandler,
80
+ resp: Any,
81
+ model: str = "ciel-runtime-upstream",
82
+ word_chunking: bool = True,
83
+ source_body: dict[str, Any] | None = None,
84
+ preserve_thinking: bool = True,
85
+ normalize_tool_use: bool = False,
86
+ provider: str = "",
87
+ *,
88
+ services: AnthropicStreamServices,
89
+ ) -> None:
90
+ """
91
+ Parse upstream Anthropic SSE and re-emit it with text_delta events buffered
92
+ to word boundaries. Non-text events are forwarded in the same SSE framing.
93
+ When the selected provider cannot preserve Anthropic's thinking passback
94
+ contract, thinking blocks are suppressed and later content block indices are
95
+ compacted.
96
+ """
97
+
98
+ ANTHROPIC_THINKING_BLOCK_TYPES = services.io.ANTHROPIC_THINKING_BLOCK_TYPES
99
+ VisibleToolCallArtifactFilter = services.io.VisibleToolCallArtifactFilter
100
+ _find_pseudo_xml_tool_start = services.io._find_pseudo_xml_tool_start
101
+ _split_word_buffer = services.io._split_word_buffer
102
+ mark_pending_channel_delivery_failed = services.io.mark_pending_channel_delivery_failed
103
+ mark_pending_channel_delivery_success = services.io.mark_pending_channel_delivery_success
104
+ remember_suppressed_thinking_passback = services.io.remember_suppressed_thinking_passback
105
+ router_client_connection_closed = services.io.router_client_connection_closed
106
+ router_log = services.io.router_log
107
+ _is_mcp_notification_wait_tool = services.tool_projection._is_mcp_notification_wait_tool
108
+ _remember_channel_injected_tool_use = services.tool_projection._remember_channel_injected_tool_use
109
+ _validate_and_fix_tool_input = services.tool_projection._validate_and_fix_tool_input
110
+ append_tool_call_log = services.tool_projection.append_tool_call_log
111
+ cap_mcp_notification_wait_tool_input = services.tool_projection.cap_mcp_notification_wait_tool_input
112
+ infer_tool_name_from_args = services.tool_projection.infer_tool_name_from_args
113
+ normalize_tool_arguments = services.tool_projection.normalize_tool_arguments
114
+ parse_pseudo_tool_calls = services.tool_projection.parse_pseudo_tool_calls
115
+ plan_mode_tool_name_for_emit = services.tool_projection.plan_mode_tool_name_for_emit
116
+ resolve_emitted_tool_name = services.tool_projection.resolve_emitted_tool_name
117
+ should_drop_duplicate_side_effect_tool_call = services.tool_policy.should_drop_duplicate_side_effect_tool_call
118
+ should_drop_emitted_tool_call = services.tool_policy.should_drop_emitted_tool_call
119
+ should_repair_anthropic_passthrough_tool_input = services.tool_policy.should_repair_anthropic_passthrough_tool_input
120
+ backfill_exit_plan_mode_allowed_prompts = services.conversation.backfill_exit_plan_mode_allowed_prompts
121
+ body_ultracode_runtime_enabled = services.conversation.body_ultracode_runtime_enabled
122
+ empty_end_turn_notice_for_body = services.conversation.empty_end_turn_notice_for_body
123
+ has_tool = services.conversation.has_tool
124
+ latest_user_intent_message_index = services.conversation.latest_user_intent_message_index
125
+ latest_user_is_claude_code_suggestion_mode = services.conversation.latest_user_is_claude_code_suggestion_mode
126
+ latest_user_tool_result_names = services.conversation.latest_user_tool_result_names
127
+ recent_synthetic_tasklist_count = services.conversation.recent_synthetic_tasklist_count
128
+ should_auto_continue_choice_question_with_tasklist = services.continuation.should_auto_continue_choice_question_with_tasklist
129
+ should_auto_exit_plan_mode = services.continuation.should_auto_exit_plan_mode
130
+ should_keep_work_alive_with_tasklist = services.continuation.should_keep_work_alive_with_tasklist
131
+ should_recover_empty_end_turn_with_tasklist = services.continuation.should_recover_empty_end_turn_with_tasklist
132
+ should_synthesize_tasklist_for_provider = services.continuation.should_synthesize_tasklist_for_provider
133
+ text_buffers: dict[int, str] = {}
134
+ pending_event_type: str | None = None
135
+ pending_event_lines: list[str] = []
136
+ saw_message_start = False
137
+ saw_message_stop = False
138
+ text_so_far = ""
139
+ saw_tool_use = False
140
+ emitted_tool_use = False
141
+ next_content_index = 0
142
+ open_content_blocks: set[int] = set()
143
+ content_index_map: dict[int, int] = {}
144
+ suppressed_content_indices: set[int] = set()
145
+ suppressed_thinking_blocks: dict[int, dict[str, Any]] = {}
146
+ suppressed_thinking_passback_blocks: list[dict[str, Any]] = []
147
+ buffered_tool_uses: dict[int, dict[str, Any]] = {}
148
+ held_pseudo_tool_text: dict[int, str] = {}
149
+ pending_message_delta: tuple[str | None, str] | None = None
150
+ pending_message_stop: tuple[str | None, str] | None = None
151
+ last_suppressed_keepalive_at = 0.0
152
+ stream_success = False
153
+ allow_tasklist_synthesis = should_synthesize_tasklist_for_provider(provider)
154
+ filter_visible_tool_call_artifacts = bool(
155
+ provider == "anthropic"
156
+ and isinstance(source_body, dict)
157
+ and (has_tool(source_body, "Workflow") or body_ultracode_runtime_enabled(source_body))
158
+ )
159
+ visible_tool_call_artifact_filters: dict[int, VisibleToolCallArtifactFilter] = {}
160
+
161
+ class ClientStreamDisconnected(Exception):
162
+ pass
163
+
164
+ def downstream_keepalive_interval() -> float:
165
+ raw = os.environ.get("CIEL_RUNTIME_ANTHROPIC_STREAM_KEEPALIVE_SECONDS")
166
+ if raw is None:
167
+ return 15.0
168
+ try:
169
+ return max(0.0, min(120.0, float(raw)))
170
+ except Exception:
171
+ return 15.0
172
+
173
+ def emit_raw(event_type: str | None, data_str: str) -> None:
174
+ try:
175
+ if event_type:
176
+ handler.wfile.write(f"event: {event_type}\ndata: {data_str}\n\n".encode())
177
+ else:
178
+ handler.wfile.write(f"data: {data_str}\n\n".encode())
179
+ handler.wfile.flush()
180
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError) as exc:
181
+ raise ClientStreamDisconnected(f"{type(exc).__name__}: {exc}") from exc
182
+
183
+ def emit_suppressed_keepalive(force: bool = False) -> None:
184
+ nonlocal last_suppressed_keepalive_at
185
+ now = time.time()
186
+ if not force and now - last_suppressed_keepalive_at < 1.0:
187
+ return
188
+ try:
189
+ handler.wfile.write(b": suppressed-thinking\n\n")
190
+ handler.wfile.flush()
191
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError) as exc:
192
+ raise ClientStreamDisconnected(f"{type(exc).__name__}: {exc}") from exc
193
+ last_suppressed_keepalive_at = now
194
+
195
+ def emit_downstream_keepalive() -> None:
196
+ try:
197
+ handler.wfile.write(b": ciel-runtime-keepalive\n\n")
198
+ handler.wfile.flush()
199
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError) as exc:
200
+ raise ClientStreamDisconnected(f"{type(exc).__name__}: {exc}") from exc
201
+
202
+ def upstream_lines_with_downstream_keepalive() -> Iterable[Any]:
203
+ interval = downstream_keepalive_interval()
204
+ if interval <= 0:
205
+ yield from resp
206
+ return
207
+ line_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
208
+
209
+ def reader() -> None:
210
+ try:
211
+ for raw_line in resp:
212
+ line_queue.put(("line", raw_line))
213
+ line_queue.put(("eof", None))
214
+ except Exception as exc:
215
+ line_queue.put(("error", exc))
216
+
217
+ threading.Thread(target=reader, daemon=True, name=f"ciel-anthropic-sse-{model}").start()
218
+ while True:
219
+ try:
220
+ kind, value = line_queue.get(timeout=interval)
221
+ except queue.Empty:
222
+ if router_client_connection_closed(handler):
223
+ raise ClientStreamDisconnected("downstream client disconnected during upstream wait")
224
+ emit_downstream_keepalive()
225
+ continue
226
+ if kind == "line":
227
+ yield value
228
+ continue
229
+ if kind == "error":
230
+ if router_client_connection_closed(handler):
231
+ raise ClientStreamDisconnected("downstream client disconnected during upstream read") from value
232
+ raise value
233
+ return
234
+
235
+ def emit_text_delta_raw(index: int, text: str) -> None:
236
+ if not text:
237
+ return
238
+ payload = {
239
+ "type": "content_block_delta",
240
+ "index": index,
241
+ "delta": {"type": "text_delta", "text": text},
242
+ }
243
+ emit_raw("content_block_delta", json.dumps(payload, ensure_ascii=False))
244
+
245
+ def emit_text_delta(index: int, text: str) -> None:
246
+ if not text:
247
+ return
248
+ if filter_visible_tool_call_artifacts:
249
+ filter_state = visible_tool_call_artifact_filters.setdefault(index, VisibleToolCallArtifactFilter())
250
+ text = filter_state.feed(text)
251
+ emit_text_delta_raw(index, text)
252
+
253
+ def finish_visible_tool_call_artifact_filter(index: int) -> None:
254
+ if not filter_visible_tool_call_artifacts:
255
+ return
256
+ filter_state = visible_tool_call_artifact_filters.pop(index, None)
257
+ if filter_state is None:
258
+ return
259
+ text = filter_state.finish()
260
+ if filter_state.stripped:
261
+ router_log(
262
+ "WARN",
263
+ f"stripped visible Anthropic workflow tool-call artifact provider={provider} model={model} index={index}",
264
+ )
265
+ emit_text_delta_raw(index, text)
266
+
267
+ def emit_text_block(index: int, text: str) -> None:
268
+ emit_raw(
269
+ "content_block_start",
270
+ json.dumps(
271
+ {
272
+ "type": "content_block_start",
273
+ "index": index,
274
+ "content_block": {"type": "text", "text": ""},
275
+ },
276
+ ensure_ascii=False,
277
+ ),
278
+ )
279
+ emit_text_delta(index, text)
280
+ finish_visible_tool_call_artifact_filter(index)
281
+ emit_raw("content_block_stop", json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False))
282
+
283
+ def flush_buffer(index: int, force: bool = False) -> None:
284
+ buf = text_buffers.get(index, "")
285
+ if not buf:
286
+ return
287
+ to_flush, remainder = _split_word_buffer(buf, force=force)
288
+ text_buffers[index] = remainder
289
+ emit_text_delta(index, to_flush)
290
+
291
+ def emit_tasklist_tool(index: int) -> None:
292
+ nonlocal emitted_tool_use
293
+ tool_id = f"toolu_anthropic_choice_{int(time.time() * 1000)}"
294
+ emit_raw(
295
+ "content_block_start",
296
+ json.dumps(
297
+ {
298
+ "type": "content_block_start",
299
+ "index": index,
300
+ "content_block": {"type": "tool_use", "id": tool_id, "name": "TaskList", "input": {}},
301
+ },
302
+ ensure_ascii=False,
303
+ ),
304
+ )
305
+ emit_raw(
306
+ "content_block_delta",
307
+ json.dumps(
308
+ {
309
+ "type": "content_block_delta",
310
+ "index": index,
311
+ "delta": {"type": "input_json_delta", "partial_json": "{}"},
312
+ },
313
+ ensure_ascii=False,
314
+ ),
315
+ )
316
+ emit_raw("content_block_stop", json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False))
317
+ emitted_tool_use = True
318
+
319
+ def emit_exit_plan_mode_tool(index: int) -> None:
320
+ nonlocal emitted_tool_use
321
+ tool_id = f"toolu_anthropic_exit_plan_{int(time.time() * 1000)}"
322
+ tool_input = {}
323
+ if isinstance(source_body, dict):
324
+ tool_input = backfill_exit_plan_mode_allowed_prompts(source_body, tool_input)
325
+ emit_raw(
326
+ "content_block_start",
327
+ json.dumps(
328
+ {
329
+ "type": "content_block_start",
330
+ "index": index,
331
+ "content_block": {"type": "tool_use", "id": tool_id, "name": "ExitPlanMode", "input": {}},
332
+ },
333
+ ensure_ascii=False,
334
+ ),
335
+ )
336
+ emit_raw(
337
+ "content_block_delta",
338
+ json.dumps(
339
+ {
340
+ "type": "content_block_delta",
341
+ "index": index,
342
+ "delta": {"type": "input_json_delta", "partial_json": json.dumps(tool_input, ensure_ascii=False)},
343
+ },
344
+ ensure_ascii=False,
345
+ ),
346
+ )
347
+ emit_raw("content_block_stop", json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False))
348
+ emitted_tool_use = True
349
+
350
+ def mapped_content_index(index: Any) -> int | None:
351
+ if not isinstance(index, int):
352
+ return None
353
+ if index in suppressed_content_indices:
354
+ return None
355
+ return content_index_map.get(index, index)
356
+
357
+ def append_suppressed_thinking_delta(index: Any, delta: dict[str, Any]) -> None:
358
+ if not isinstance(index, int):
359
+ return
360
+ block = suppressed_thinking_blocks.get(index)
361
+ if not isinstance(block, dict):
362
+ return
363
+ delta_type = delta.get("type")
364
+ if delta_type == "thinking_delta":
365
+ block["thinking"] = str(block.get("thinking") or "") + str(delta.get("thinking") or "")
366
+ elif delta_type == "signature_delta":
367
+ block["signature"] = str(delta.get("signature") or "")
368
+
369
+ def finish_suppressed_thinking_block(index: Any) -> None:
370
+ if not isinstance(index, int):
371
+ return
372
+ block = suppressed_thinking_blocks.pop(index, None)
373
+ if isinstance(block, dict) and block.get("type") in ANTHROPIC_THINKING_BLOCK_TYPES:
374
+ suppressed_thinking_passback_blocks.append(block)
375
+
376
+ def flush_suppressed_thinking_passback() -> None:
377
+ if preserve_thinking or not suppressed_thinking_passback_blocks:
378
+ return
379
+ if source_body is not None and latest_user_is_claude_code_suggestion_mode(source_body):
380
+ router_log(
381
+ "DEBUG",
382
+ f"discarded suppressed Anthropic thinking passback blocks for suggestion-mode request "
383
+ f"provider={provider} model={model} blocks={len(suppressed_thinking_passback_blocks)}",
384
+ )
385
+ suppressed_thinking_passback_blocks.clear()
386
+ return
387
+ remember_suppressed_thinking_passback(provider, model, suppressed_thinking_passback_blocks)
388
+ suppressed_thinking_passback_blocks.clear()
389
+
390
+ def patched_message_delta(stop_reason: str) -> str:
391
+ event: dict[str, Any] = {}
392
+ if pending_message_delta is not None:
393
+ try:
394
+ parsed = json.loads(pending_message_delta[1])
395
+ if isinstance(parsed, dict):
396
+ event = dict(parsed)
397
+ except Exception:
398
+ event = {}
399
+ if not event:
400
+ event = {
401
+ "type": "message_delta",
402
+ "delta": {"stop_reason": None, "stop_sequence": None},
403
+ "usage": {"output_tokens": max(1, len(text_so_far) // 4)},
404
+ }
405
+ delta = event.get("delta") if isinstance(event.get("delta"), dict) else {}
406
+ patched_delta = dict(delta)
407
+ patched_delta["stop_reason"] = stop_reason
408
+ patched_delta.setdefault("stop_sequence", None)
409
+ event["delta"] = patched_delta
410
+ event.setdefault("type", "message_delta")
411
+ event.setdefault("usage", {"output_tokens": max(1, len(text_so_far) // 4)})
412
+ return json.dumps(event, ensure_ascii=False)
413
+
414
+ def emit_pending_message_end(default_stop_reason: str = "end_turn") -> None:
415
+ stop_reason = default_stop_reason
416
+ if pending_message_delta is not None:
417
+ try:
418
+ parsed = json.loads(pending_message_delta[1])
419
+ if isinstance(parsed, dict):
420
+ delta = parsed.get("delta") if isinstance(parsed.get("delta"), dict) else {}
421
+ stop_reason = str(delta.get("stop_reason") or stop_reason)
422
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
423
+ router_log("WARN", f"anthropic_stream_pending_delta_parse_failed error={type(exc).__name__}: {exc}")
424
+ emit_raw(
425
+ pending_message_delta[0] if pending_message_delta is not None else "message_delta",
426
+ patched_message_delta(stop_reason),
427
+ )
428
+ emit_raw(
429
+ pending_message_stop[0] if pending_message_stop is not None else "message_stop",
430
+ pending_message_stop[1] if pending_message_stop is not None else "{\"type\":\"message_stop\"}",
431
+ )
432
+
433
+ def recover_hidden_only_response_if_needed() -> None:
434
+ nonlocal next_content_index, saw_tool_use, emitted_tool_use, text_so_far, pending_message_delta
435
+ recovery_reason = ""
436
+ latest_names: list[str] = []
437
+ synthetic_count = 0
438
+ has_tasklist_tool = False
439
+ if source_body is not None:
440
+ try:
441
+ latest_names = latest_user_tool_result_names(source_body)
442
+ intent_index = latest_user_intent_message_index(source_body)
443
+ synthetic_count = recent_synthetic_tasklist_count(source_body, after_message_index=intent_index)
444
+ has_tasklist_tool = has_tool(source_body, "TaskList")
445
+ if emitted_tool_use:
446
+ recovery_reason = ""
447
+ elif allow_tasklist_synthesis and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, []):
448
+ recovery_reason = "hidden-only" if suppressed_thinking_passback_blocks else "empty"
449
+ elif allow_tasklist_synthesis and should_keep_work_alive_with_tasklist(source_body, text_so_far, []):
450
+ recovery_reason = "keepalive"
451
+ except Exception as exc:
452
+ router_log(
453
+ "WARN",
454
+ "anthropic_hidden_recovery_state_error "
455
+ f"provider={provider} model={model} error={type(exc).__name__}: {exc}",
456
+ )
457
+ if recovery_reason:
458
+ router_log(
459
+ "WARN",
460
+ f"auto-synthesized TaskList from {recovery_reason} Anthropic-compatible stream "
461
+ f"latest_tool_results={','.join(latest_names) or '-'} synthetic_tasklists={synthetic_count}",
462
+ )
463
+ emit_tasklist_tool(next_content_index)
464
+ next_content_index += 1
465
+ saw_tool_use = True
466
+ pending_message_delta = (
467
+ pending_message_delta[0] if pending_message_delta is not None else "message_delta",
468
+ patched_message_delta("tool_use"),
469
+ )
470
+ return
471
+ if text_so_far.strip() or emitted_tool_use:
472
+ if suppressed_thinking_passback_blocks:
473
+ router_log(
474
+ "DEBUG",
475
+ "anthropic_hidden_recovery_skipped "
476
+ f"provider={provider} model={model} reason=visible_or_tool "
477
+ f"text_len={len(text_so_far.strip())} emitted_tool_use={emitted_tool_use} "
478
+ f"latest_tool_results={','.join(latest_names) or '-'} "
479
+ f"synthetic_tasklists={synthetic_count} suppressed_blocks={len(suppressed_thinking_passback_blocks)}",
480
+ )
481
+ return
482
+ if not suppressed_thinking_passback_blocks:
483
+ return
484
+ router_log(
485
+ "WARN",
486
+ "anthropic_hidden_recovery_not_applicable "
487
+ f"provider={provider} model={model} has_tasklist={has_tasklist_tool} "
488
+ f"latest_tool_results={','.join(latest_names) or '-'} synthetic_tasklists={synthetic_count} "
489
+ f"suppressed_blocks={len(suppressed_thinking_passback_blocks)}",
490
+ )
491
+ notice = empty_end_turn_notice_for_body(source_body) if source_body is not None else ""
492
+ router_log("WARN", f"anthropic_hidden_only_stream provider={provider} model={model}")
493
+ emit_text_block(next_content_index, notice)
494
+ next_content_index += 1
495
+ if notice:
496
+ text_so_far = notice
497
+ pending_message_delta = (
498
+ pending_message_delta[0] if pending_message_delta is not None else "message_delta",
499
+ patched_message_delta("end_turn"),
500
+ )
501
+
502
+ def append_tool_partial(tool_state: dict[str, Any], partial: Any) -> None:
503
+ if partial is None:
504
+ return
505
+ if isinstance(partial, str):
506
+ tool_state["partial_json"] = str(tool_state.get("partial_json") or "") + partial
507
+ else:
508
+ tool_state["partial_json"] = str(tool_state.get("partial_json") or "") + json.dumps(partial, ensure_ascii=False)
509
+
510
+ def emit_normalized_tool_use(index: int, tool_state: dict[str, Any]) -> None:
511
+ nonlocal emitted_tool_use
512
+ raw_name = str(tool_state.get("name") or "")
513
+ raw_args = str(tool_state.get("partial_json") or "")
514
+ parsed_args = normalize_tool_arguments(raw_name, raw_args)
515
+ if not raw_name:
516
+ raw_name = infer_tool_name_from_args(parsed_args)
517
+ matched_name = resolve_emitted_tool_name(raw_name, source_body)
518
+ if not matched_name:
519
+ matched_name = infer_tool_name_from_args(parsed_args)
520
+ fixed_input = _validate_and_fix_tool_input(matched_name, parsed_args, source_body)
521
+ if isinstance(source_body, dict):
522
+ mapped_name, mapped_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
523
+ if mapped_name is None:
524
+ router_log(
525
+ "WARN",
526
+ f"dropped upstream tool_use before emit raw_name={raw_name!r} matched_name={matched_name!r}",
527
+ )
528
+ return
529
+ matched_name, fixed_input = mapped_name, mapped_input
530
+ fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
531
+ if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body):
532
+ return
533
+ if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
534
+ return
535
+ tool_id = str(tool_state.get("id") or f"toolu_anthropic_{int(time.time() * 1000)}_{index}")
536
+ _remember_channel_injected_tool_use(source_body, tool_id, matched_name, fixed_input)
537
+ append_tool_call_log(
538
+ "anthropic_stream_tool_call",
539
+ {
540
+ "model": model,
541
+ "raw_name": raw_name,
542
+ "matched_name": matched_name,
543
+ "raw_arguments": raw_args,
544
+ "emitted_input": fixed_input,
545
+ "sse_index": index,
546
+ },
547
+ )
548
+ emit_raw(
549
+ "content_block_start",
550
+ json.dumps(
551
+ {
552
+ "type": "content_block_start",
553
+ "index": index,
554
+ "content_block": {"type": "tool_use", "id": tool_id, "name": matched_name, "input": {}},
555
+ },
556
+ ensure_ascii=False,
557
+ ),
558
+ )
559
+ emit_raw(
560
+ "content_block_delta",
561
+ json.dumps(
562
+ {
563
+ "type": "content_block_delta",
564
+ "index": index,
565
+ "delta": {"type": "input_json_delta", "partial_json": json.dumps(fixed_input, ensure_ascii=False)},
566
+ },
567
+ ensure_ascii=False,
568
+ ),
569
+ )
570
+ emit_raw("content_block_stop", json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False))
571
+ emitted_tool_use = True
572
+
573
+ def emit_pseudo_tool_uses(pseudo_tool_calls: list[dict[str, Any]]) -> bool:
574
+ nonlocal next_content_index, saw_tool_use
575
+ if not pseudo_tool_calls:
576
+ return False
577
+ for call in pseudo_tool_calls:
578
+ fn = call.get("function") if isinstance(call, dict) else {}
579
+ if not isinstance(fn, dict) or not fn.get("name"):
580
+ continue
581
+ tool_index = next_content_index
582
+ next_content_index += 1
583
+ tool_state = {
584
+ "id": str(call.get("id") or ""),
585
+ "name": str(fn.get("name") or ""),
586
+ "partial_json": json.dumps(fn.get("arguments") or {}, ensure_ascii=False),
587
+ }
588
+ emit_normalized_tool_use(tool_index, tool_state)
589
+ saw_tool_use = True
590
+ return True
591
+
592
+ def process_event(event_type: str | None, data_str: str) -> None:
593
+ nonlocal saw_message_start, saw_message_stop, text_so_far, saw_tool_use, emitted_tool_use, next_content_index, pending_message_delta, pending_message_stop
594
+ try:
595
+ event = json.loads(data_str)
596
+ except Exception:
597
+ emit_raw(event_type, data_str)
598
+ return
599
+ if not isinstance(event, dict):
600
+ emit_raw(event_type, data_str)
601
+ return
602
+ evt_type = event.get("type") or event_type
603
+ if evt_type == "message_start":
604
+ saw_message_start = True
605
+ elif evt_type == "message_stop":
606
+ saw_message_stop = True
607
+ pending_message_stop = (event_type, data_str)
608
+ return
609
+ elif evt_type == "content_block_start":
610
+ index = event.get("index")
611
+ content_block = event.get("content_block") if isinstance(event.get("content_block"), dict) else {}
612
+ mapped_index: int | None = None
613
+ if isinstance(index, int):
614
+ if not preserve_thinking and content_block.get("type") in ANTHROPIC_THINKING_BLOCK_TYPES:
615
+ suppressed_content_indices.add(index)
616
+ suppressed_thinking_blocks[index] = dict(content_block)
617
+ router_log("WARN", f"suppressed Anthropic thinking response block for non-Anthropic provider model={model}")
618
+ emit_suppressed_keepalive(force=True)
619
+ return
620
+ if index in content_index_map:
621
+ mapped_index = content_index_map[index]
622
+ else:
623
+ mapped_index = next_content_index
624
+ content_index_map[index] = mapped_index
625
+ next_content_index += 1
626
+ open_content_blocks.add(mapped_index)
627
+ patched = dict(event)
628
+ patched["index"] = mapped_index
629
+ event = patched
630
+ data_str = json.dumps(event, ensure_ascii=False)
631
+ if content_block.get("type") == "tool_use":
632
+ saw_tool_use = True
633
+ tool_name = str(content_block.get("name") or "")
634
+ should_buffer_tool_use = bool(
635
+ mapped_index is not None
636
+ and (
637
+ normalize_tool_use
638
+ or _is_mcp_notification_wait_tool(tool_name)
639
+ or should_repair_anthropic_passthrough_tool_input(provider, tool_name, source_body)
640
+ )
641
+ )
642
+ if should_buffer_tool_use and mapped_index is not None:
643
+ buffered_tool_uses[mapped_index] = {
644
+ "id": str(content_block.get("id") or ""),
645
+ "name": tool_name,
646
+ "partial_json": "",
647
+ }
648
+ initial_input = content_block.get("input")
649
+ if isinstance(initial_input, dict) and initial_input:
650
+ append_tool_partial(buffered_tool_uses[mapped_index], initial_input)
651
+ return
652
+ emitted_tool_use = True
653
+ elif evt_type == "content_block_stop":
654
+ index = event.get("index")
655
+ mapped_index = mapped_content_index(index)
656
+ if isinstance(index, int) and mapped_index is None:
657
+ finish_suppressed_thinking_block(index)
658
+ return
659
+ if mapped_index is not None:
660
+ open_content_blocks.discard(mapped_index)
661
+ if mapped_index in buffered_tool_uses:
662
+ emit_normalized_tool_use(mapped_index, buffered_tool_uses.pop(mapped_index))
663
+ return
664
+ patched = dict(event)
665
+ patched["index"] = mapped_index
666
+ data_str = json.dumps(patched, ensure_ascii=False)
667
+ if isinstance(mapped_index, int) and word_chunking:
668
+ flush_buffer(mapped_index, force=True)
669
+ if isinstance(mapped_index, int) and mapped_index in held_pseudo_tool_text:
670
+ held_text = held_pseudo_tool_text.pop(mapped_index)
671
+ visible_text, pseudo_tool_calls = parse_pseudo_tool_calls(held_text, source_body)
672
+ if pseudo_tool_calls:
673
+ if visible_text.strip():
674
+ emit_text_delta(mapped_index, visible_text)
675
+ finish_visible_tool_call_artifact_filter(mapped_index)
676
+ emit_raw(event_type, data_str)
677
+ emit_pseudo_tool_uses(pseudo_tool_calls)
678
+ return
679
+ else:
680
+ emit_text_delta(mapped_index, held_text)
681
+ finish_visible_tool_call_artifact_filter(mapped_index)
682
+ emit_raw(event_type, data_str)
683
+ return
684
+ elif evt_type == "message_delta":
685
+ delta = event.get("delta") if isinstance(event.get("delta"), dict) else {}
686
+ stop_reason = str(delta.get("stop_reason") or "")
687
+ tool_calls = [{"type": "tool_use"}] if emitted_tool_use else []
688
+ if stop_reason == "tool_use" and not emitted_tool_use:
689
+ for index in list(text_buffers.keys()):
690
+ flush_buffer(index, force=True)
691
+ if source_body is not None and should_auto_exit_plan_mode(source_body, text_so_far, []):
692
+ router_log("WARN", "auto-synthesized ExitPlanMode from malformed Anthropic-compatible tool_use stream")
693
+ emit_exit_plan_mode_tool(next_content_index)
694
+ next_content_index += 1
695
+ saw_tool_use = True
696
+ pending_message_delta = (
697
+ event_type,
698
+ patched_message_delta("tool_use"),
699
+ )
700
+ return
701
+ if (
702
+ allow_tasklist_synthesis
703
+ and source_body is not None
704
+ and should_keep_work_alive_with_tasklist(source_body, text_so_far, [])
705
+ ):
706
+ router_log("WARN", "auto-synthesized TaskList after dropped Anthropic-compatible tool_use")
707
+ emit_tasklist_tool(next_content_index)
708
+ next_content_index += 1
709
+ saw_tool_use = True
710
+ pending_message_delta = (
711
+ event_type,
712
+ patched_message_delta("tool_use"),
713
+ )
714
+ return
715
+ router_log(
716
+ "WARN",
717
+ f"downgraded malformed Anthropic-compatible tool_use stop without emitted tool "
718
+ f"provider={provider} model={model} text_len={len(text_so_far.strip())}",
719
+ )
720
+ pending_message_delta = (
721
+ event_type,
722
+ patched_message_delta("end_turn"),
723
+ )
724
+ return
725
+ if emitted_tool_use and stop_reason == "end_turn":
726
+ patched = dict(event)
727
+ patched_delta = dict(delta)
728
+ patched_delta["stop_reason"] = "tool_use"
729
+ patched["delta"] = patched_delta
730
+ pending_message_delta = (event_type, json.dumps(patched, ensure_ascii=False))
731
+ return
732
+ if (
733
+ allow_tasklist_synthesis
734
+ and
735
+ stop_reason == "end_turn"
736
+ and source_body is not None
737
+ and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls)
738
+ ):
739
+ for index in list(text_buffers.keys()):
740
+ flush_buffer(index, force=True)
741
+ router_log("WARN", "auto-synthesized TaskList after clarification question Anthropic-compatible stream")
742
+ emit_tasklist_tool(next_content_index)
743
+ next_content_index += 1
744
+ saw_tool_use = True
745
+ patched = dict(event)
746
+ patched_delta = dict(delta)
747
+ patched_delta["stop_reason"] = "tool_use"
748
+ patched["delta"] = patched_delta
749
+ pending_message_delta = (event_type, json.dumps(patched, ensure_ascii=False))
750
+ return
751
+ should_recover = (
752
+ allow_tasklist_synthesis
753
+ and source_body is not None
754
+ and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls)
755
+ )
756
+ should_keep_alive = (
757
+ allow_tasklist_synthesis
758
+ and
759
+ source_body is not None
760
+ and not should_recover
761
+ and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls)
762
+ )
763
+ if should_recover or should_keep_alive:
764
+ for index in list(text_buffers.keys()):
765
+ flush_buffer(index, force=True)
766
+ reason = "empty" if should_recover else "keepalive"
767
+ router_log(
768
+ "WARN",
769
+ f"auto-synthesized TaskList from {reason} Anthropic-compatible message_delta "
770
+ f"stop_reason={stop_reason or '-'}",
771
+ )
772
+ emit_tasklist_tool(next_content_index)
773
+ next_content_index += 1
774
+ saw_tool_use = True
775
+ patched = dict(event)
776
+ patched_delta = dict(delta)
777
+ patched_delta["stop_reason"] = "tool_use"
778
+ patched["delta"] = patched_delta
779
+ pending_message_delta = (event_type, json.dumps(patched, ensure_ascii=False))
780
+ return
781
+ pending_message_delta = (event_type, data_str)
782
+ return
783
+ if evt_type == "content_block_delta":
784
+ delta = event.get("delta") if isinstance(event.get("delta"), dict) else {}
785
+ index = event.get("index")
786
+ mapped_index = mapped_content_index(index)
787
+ if isinstance(index, int) and mapped_index is None:
788
+ append_suppressed_thinking_delta(index, delta)
789
+ emit_suppressed_keepalive()
790
+ return
791
+ if not preserve_thinking and delta.get("type") in {"thinking_delta", "signature_delta"}:
792
+ emit_suppressed_keepalive()
793
+ return
794
+ if isinstance(mapped_index, int) and mapped_index in buffered_tool_uses:
795
+ if delta.get("type") == "input_json_delta":
796
+ append_tool_partial(buffered_tool_uses[mapped_index], delta.get("partial_json"))
797
+ return
798
+ if mapped_index is not None:
799
+ patched = dict(event)
800
+ patched["index"] = mapped_index
801
+ event = patched
802
+ data_str = json.dumps(event, ensure_ascii=False)
803
+ if isinstance(mapped_index, int) and delta.get("type") == "text_delta":
804
+ text = delta.get("text") or ""
805
+ if not text:
806
+ return
807
+ text_so_far += text
808
+ if provider != "anthropic" and mapped_index in held_pseudo_tool_text:
809
+ held_pseudo_tool_text[mapped_index] += text
810
+ return
811
+ pseudo_start = _find_pseudo_xml_tool_start(text, source_body) if provider != "anthropic" else -1
812
+ if pseudo_start >= 0:
813
+ prefix = text[:pseudo_start]
814
+ held_pseudo_tool_text[mapped_index] = text[pseudo_start:]
815
+ if not prefix:
816
+ return
817
+ if not word_chunking:
818
+ emit_text_delta(mapped_index, prefix)
819
+ return
820
+ text_buffers[mapped_index] = text_buffers.get(mapped_index, "") + prefix
821
+ flush_buffer(mapped_index, force=False)
822
+ return
823
+ if not word_chunking:
824
+ emit_text_delta(mapped_index, text)
825
+ return
826
+ text_buffers[mapped_index] = text_buffers.get(mapped_index, "") + text
827
+ flush_buffer(mapped_index, force=False)
828
+ return
829
+ emit_raw(event_type, data_str)
830
+ return
831
+ if evt_type == "content_block_stop":
832
+ index = event.get("index")
833
+ mapped_index = mapped_content_index(index)
834
+ if isinstance(index, int) and mapped_index is None:
835
+ finish_suppressed_thinking_block(index)
836
+ return
837
+ if mapped_index is not None:
838
+ if mapped_index in buffered_tool_uses:
839
+ emit_normalized_tool_use(mapped_index, buffered_tool_uses.pop(mapped_index))
840
+ return
841
+ patched = dict(event)
842
+ patched["index"] = mapped_index
843
+ event = patched
844
+ data_str = json.dumps(event, ensure_ascii=False)
845
+ if isinstance(mapped_index, int) and word_chunking:
846
+ flush_buffer(mapped_index, force=True)
847
+ if isinstance(mapped_index, int):
848
+ finish_visible_tool_call_artifact_filter(mapped_index)
849
+ emit_raw(event_type, data_str)
850
+ return
851
+ if evt_type == "message_stop":
852
+ flush_suppressed_thinking_passback()
853
+ emit_raw(event_type, data_str)
854
+
855
+ try:
856
+ for raw in upstream_lines_with_downstream_keepalive():
857
+ line = raw.decode("utf-8", errors="ignore")
858
+ stripped = line.rstrip("\r\n")
859
+ if stripped == "":
860
+ if pending_event_lines:
861
+ data_str = "\n".join(pending_event_lines)
862
+ process_event(pending_event_type, data_str)
863
+ pending_event_type = None
864
+ pending_event_lines = []
865
+ continue
866
+ if stripped.startswith("event:"):
867
+ pending_event_type = stripped[len("event:"):].strip() or None
868
+ continue
869
+ if stripped.startswith("data:"):
870
+ pending_event_lines.append(stripped[len("data:"):].lstrip())
871
+ continue
872
+ if pending_event_lines:
873
+ data_str = "\n".join(pending_event_lines)
874
+ process_event(pending_event_type, data_str)
875
+ for index in list(text_buffers.keys()):
876
+ flush_buffer(index, force=True)
877
+ for index in list(suppressed_thinking_blocks.keys()):
878
+ finish_suppressed_thinking_block(index)
879
+ recover_hidden_only_response_if_needed()
880
+ flush_suppressed_thinking_passback()
881
+ if pending_message_delta is not None or pending_message_stop is not None:
882
+ emit_pending_message_end()
883
+ stream_success = bool(saw_message_stop)
884
+ except ClientStreamDisconnected as exc:
885
+ mark_pending_channel_delivery_failed(handler, "anthropic_stream_client_disconnected")
886
+ router_log(
887
+ "WARN",
888
+ f"anthropic_sse_client_disconnected model={model} "
889
+ f"text_len={len(text_so_far)} emitted_tool_use={emitted_tool_use} "
890
+ f"suppressed_blocks={len(suppressed_thinking_passback_blocks) + len(suppressed_thinking_blocks)} "
891
+ f"error={exc}",
892
+ )
893
+ except Exception as exc:
894
+ router_log("ERROR", f"anthropic_sse_forward_error model={model} error={type(exc).__name__}: {exc}")
895
+ try:
896
+ if pending_event_lines:
897
+ data_str = "\n".join(pending_event_lines)
898
+ process_event(pending_event_type, data_str)
899
+ pending_event_lines = []
900
+ pending_event_type = None
901
+ for index in list(text_buffers.keys()):
902
+ flush_buffer(index, force=True)
903
+ for index in list(suppressed_thinking_blocks.keys()):
904
+ finish_suppressed_thinking_block(index)
905
+ recover_hidden_only_response_if_needed()
906
+ flush_suppressed_thinking_passback()
907
+ if pending_message_delta is not None or pending_message_stop is not None:
908
+ emit_pending_message_end()
909
+ for index in sorted(open_content_blocks):
910
+ emit_raw("content_block_stop", json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False))
911
+ open_content_blocks.clear()
912
+ if not saw_message_stop:
913
+ if not saw_message_start:
914
+ payload = {
915
+ "type": "message_start",
916
+ "message": {
917
+ "id": f"msg_ciel_runtime_forward_{int(time.time() * 1000)}",
918
+ "type": "message",
919
+ "role": "assistant",
920
+ "content": [],
921
+ "model": model,
922
+ "stop_reason": None,
923
+ "stop_sequence": None,
924
+ "usage": {"input_tokens": 0, "output_tokens": 0},
925
+ },
926
+ }
927
+ emit_raw("message_start", json.dumps(payload, ensure_ascii=False))
928
+ emit_raw(
929
+ "content_block_start",
930
+ json.dumps({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, ensure_ascii=False),
931
+ )
932
+ emit_text_delta(0, f"Upstream stream error: {type(exc).__name__}: {exc}")
933
+ emit_raw("content_block_stop", json.dumps({"type": "content_block_stop", "index": 0}, ensure_ascii=False))
934
+ emit_raw(
935
+ "message_delta",
936
+ json.dumps({"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}, ensure_ascii=False),
937
+ )
938
+ emit_raw("message_stop", "{\"type\":\"message_stop\"}")
939
+ except Exception as exc:
940
+ router_log("WARN", f"anthropic_stream_fallback_emit_failed error={type(exc).__name__}: {exc}")
941
+ finally:
942
+ if stream_success:
943
+ mark_pending_channel_delivery_success(handler, "anthropic_stream_message_stop")
944
+ else:
945
+ reason = str(getattr(handler, "_ciel_runtime_channel_delivery_reason", "anthropic_stream_incomplete") or "anthropic_stream_incomplete")
946
+ mark_pending_channel_delivery_failed(handler, reason)
947
+ try:
948
+ resp.close()
949
+ except Exception as exc:
950
+ router_log("WARN", f"anthropic_stream_response_close_failed error={type(exc).__name__}: {exc}")
951
+
952
+
953
+ @dataclass(frozen=True, slots=True)
954
+ class OllamaStreamIO:
955
+ UpstreamClientDisconnected: type[BaseException]
956
+ VisibleThinkingMarkupFilter: type[Any]
957
+ _split_word_buffer: Callable[..., Any]
958
+ estimate_tokens: Callable[..., Any]
959
+ iter_upstream_lines_until_client_disconnect: Callable[..., Any]
960
+ mark_pending_channel_delivery_failed: Callable[..., Any]
961
+ mark_pending_channel_delivery_success: Callable[..., Any]
962
+ router_log: Callable[..., Any]
963
+ write_router_activity: Callable[..., Any]
964
+
965
+
966
+ @dataclass(frozen=True, slots=True)
967
+ class OllamaStreamTrace:
968
+ dump_response_for_trace: Callable[..., Any]
969
+ finish_outgoing_sse_trace: Callable[..., Any]
970
+ make_outgoing_sse_trace: Callable[..., Any]
971
+ record_outgoing_sse_event: Callable[..., Any]
972
+
973
+
974
+ @dataclass(frozen=True, slots=True)
975
+ class OllamaToolProjection:
976
+ _remember_channel_injected_tool_use: Callable[..., Any]
977
+ _validate_and_fix_tool_input: Callable[..., Any]
978
+ append_tool_call_log: Callable[..., Any]
979
+ cap_mcp_notification_wait_tool_input: Callable[..., Any]
980
+ normalize_tool_arguments: Callable[..., Any]
981
+ plan_mode_tool_name_for_emit: Callable[..., Any]
982
+ resolve_emitted_tool_name: Callable[..., Any]
983
+ should_drop_duplicate_side_effect_tool_call: Callable[..., Any]
984
+ should_drop_emitted_tool_call: Callable[..., Any]
985
+
986
+
987
+ @dataclass(frozen=True, slots=True)
988
+ class OllamaContinuationPolicy:
989
+ empty_end_turn_notice_for_body: Callable[..., Any]
990
+ should_auto_continue_choice_question_with_tasklist: Callable[..., Any]
991
+ should_auto_enter_plan_mode: Callable[..., Any]
992
+ should_keep_work_alive_with_tasklist: Callable[..., Any]
993
+ should_recover_empty_end_turn_with_tasklist: Callable[..., Any]
994
+
995
+
996
+ @dataclass(frozen=True, slots=True)
997
+ class OllamaStreamServices:
998
+ io: OllamaStreamIO
999
+ trace: OllamaStreamTrace
1000
+ tool_projection: OllamaToolProjection
1001
+ continuation: OllamaContinuationPolicy
1002
+
1003
+
1004
+ def ollama_stream_to_anthropic_sse(
1005
+ handler: BaseHTTPRequestHandler,
1006
+ resp: Any,
1007
+ model: str,
1008
+ word_chunking: bool = False,
1009
+ provider: str = "ollama",
1010
+ source_body: dict[str, Any] | None = None,
1011
+ idle_timeout: float = 30.0,
1012
+ *,
1013
+ services: OllamaStreamServices,
1014
+ ) -> None:
1015
+ """Stream Ollama NDJSON /api/chat response as Anthropic SSE /v1/messages format."""
1016
+
1017
+ UpstreamClientDisconnected = services.io.UpstreamClientDisconnected
1018
+ VisibleThinkingMarkupFilter = services.io.VisibleThinkingMarkupFilter
1019
+ _split_word_buffer = services.io._split_word_buffer
1020
+ estimate_tokens = services.io.estimate_tokens
1021
+ iter_upstream_lines_until_client_disconnect = services.io.iter_upstream_lines_until_client_disconnect
1022
+ mark_pending_channel_delivery_failed = services.io.mark_pending_channel_delivery_failed
1023
+ mark_pending_channel_delivery_success = services.io.mark_pending_channel_delivery_success
1024
+ router_log = services.io.router_log
1025
+ write_router_activity = services.io.write_router_activity
1026
+ dump_response_for_trace = services.trace.dump_response_for_trace
1027
+ finish_outgoing_sse_trace = services.trace.finish_outgoing_sse_trace
1028
+ make_outgoing_sse_trace = services.trace.make_outgoing_sse_trace
1029
+ record_outgoing_sse_event = services.trace.record_outgoing_sse_event
1030
+ _remember_channel_injected_tool_use = services.tool_projection._remember_channel_injected_tool_use
1031
+ _validate_and_fix_tool_input = services.tool_projection._validate_and_fix_tool_input
1032
+ append_tool_call_log = services.tool_projection.append_tool_call_log
1033
+ cap_mcp_notification_wait_tool_input = services.tool_projection.cap_mcp_notification_wait_tool_input
1034
+ normalize_tool_arguments = services.tool_projection.normalize_tool_arguments
1035
+ plan_mode_tool_name_for_emit = services.tool_projection.plan_mode_tool_name_for_emit
1036
+ resolve_emitted_tool_name = services.tool_projection.resolve_emitted_tool_name
1037
+ should_drop_duplicate_side_effect_tool_call = services.tool_projection.should_drop_duplicate_side_effect_tool_call
1038
+ should_drop_emitted_tool_call = services.tool_projection.should_drop_emitted_tool_call
1039
+ empty_end_turn_notice_for_body = services.continuation.empty_end_turn_notice_for_body
1040
+ should_auto_continue_choice_question_with_tasklist = services.continuation.should_auto_continue_choice_question_with_tasklist
1041
+ should_auto_enter_plan_mode = services.continuation.should_auto_enter_plan_mode
1042
+ should_keep_work_alive_with_tasklist = services.continuation.should_keep_work_alive_with_tasklist
1043
+ should_recover_empty_end_turn_with_tasklist = services.continuation.should_recover_empty_end_turn_with_tasklist
1044
+ handler.send_response(200)
1045
+ handler.send_header("content-type", "text/event-stream")
1046
+ handler.send_header("cache-control", "no-cache")
1047
+ handler.send_header("connection", "close")
1048
+ handler.end_headers()
1049
+ msg_id = f"msg_ollama_{int(time.time() * 1000)}"
1050
+ started = False
1051
+ text_started = False
1052
+ text_suppressed_for_plan = False
1053
+ next_content_index = 0
1054
+ text_index: int | None = None
1055
+ text_block_open = False
1056
+ text_so_far = ""
1057
+ text_buffer = ""
1058
+ tool_calls: list[dict[str, Any]] = []
1059
+ tool_indices: list[int] = []
1060
+ stopped_tool_indices: set[int] = set()
1061
+ input_tokens = estimate_tokens(source_body) if isinstance(source_body, dict) else 0
1062
+ output_tokens = 0
1063
+ chunk: dict[str, Any] = {}
1064
+ chunks_seen = 0
1065
+ last_activity_update = 0.0
1066
+ thinking_markup_filter = VisibleThinkingMarkupFilter()
1067
+ thinking_markup_suppressed = False
1068
+ sse_trace = make_outgoing_sse_trace(provider, model, "ollama_stream", source_body)
1069
+ sse_trace_outcome = "started"
1070
+ sse_trace_error: str | None = None
1071
+
1072
+ def emit(event_name: str, payload: dict[str, Any]) -> None:
1073
+ try:
1074
+ record_outgoing_sse_event(sse_trace, event_name, payload)
1075
+ handler.wfile.write(f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode())
1076
+ handler.wfile.flush()
1077
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError) as exc:
1078
+ raise UpstreamClientDisconnected(f"downstream write failed: {type(exc).__name__}: {exc}") from exc
1079
+
1080
+ def ensure_message_started() -> None:
1081
+ nonlocal started
1082
+ if started:
1083
+ return
1084
+ started = True
1085
+ event = {
1086
+ "type": "message_start",
1087
+ "message": {
1088
+ "id": msg_id,
1089
+ "type": "message",
1090
+ "role": "assistant",
1091
+ "content": [],
1092
+ "model": model,
1093
+ "stop_reason": None,
1094
+ "stop_sequence": None,
1095
+ "usage": {"input_tokens": input_tokens, "output_tokens": 0},
1096
+ },
1097
+ }
1098
+ emit("message_start", event)
1099
+
1100
+ def emit_text_block(index: int, text: str) -> None:
1101
+ emit("content_block_start", {"type": "content_block_start", "index": index, "content_block": {"type": "text", "text": ""}})
1102
+ if text:
1103
+ emit("content_block_delta", {"type": "content_block_delta", "index": index, "delta": {"type": "text_delta", "text": text}})
1104
+ emit("content_block_stop", {"type": "content_block_stop", "index": index})
1105
+
1106
+ def emit_tool_block(index: int, tool_id: str, name: str, tool_input: dict[str, Any]) -> None:
1107
+ emit(
1108
+ "content_block_start",
1109
+ {
1110
+ "type": "content_block_start",
1111
+ "index": index,
1112
+ "content_block": {
1113
+ "type": "tool_use",
1114
+ "id": tool_id,
1115
+ "name": name,
1116
+ "input": {},
1117
+ },
1118
+ },
1119
+ )
1120
+ emit(
1121
+ "content_block_delta",
1122
+ {
1123
+ "type": "content_block_delta",
1124
+ "index": index,
1125
+ "delta": {
1126
+ "type": "input_json_delta",
1127
+ "partial_json": json.dumps(tool_input, ensure_ascii=False),
1128
+ },
1129
+ },
1130
+ )
1131
+ emit("content_block_stop", {"type": "content_block_stop", "index": index})
1132
+ stopped_tool_indices.add(index)
1133
+
1134
+ def open_text_block() -> int:
1135
+ nonlocal next_content_index, text_block_open, text_index, text_started
1136
+ if text_block_open and text_index is not None:
1137
+ return text_index
1138
+ text_index = next_content_index
1139
+ next_content_index += 1
1140
+ text_started = True
1141
+ text_block_open = True
1142
+ emit(
1143
+ "content_block_start",
1144
+ {
1145
+ "type": "content_block_start",
1146
+ "index": text_index,
1147
+ "content_block": {"type": "text", "text": ""},
1148
+ },
1149
+ )
1150
+ return text_index
1151
+
1152
+ def close_text_block() -> None:
1153
+ nonlocal text_block_open, text_buffer
1154
+ if not text_block_open or text_index is None:
1155
+ return
1156
+ if word_chunking and text_buffer:
1157
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=True)
1158
+ if to_flush:
1159
+ emit(
1160
+ "content_block_delta",
1161
+ {
1162
+ "type": "content_block_delta",
1163
+ "index": text_index,
1164
+ "delta": {"type": "text_delta", "text": to_flush},
1165
+ },
1166
+ )
1167
+ emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1168
+ text_block_open = False
1169
+
1170
+ def update_stream_activity(force: bool = False) -> None:
1171
+ nonlocal last_activity_update
1172
+ now = time.time()
1173
+ if not force and now - last_activity_update < 0.5:
1174
+ return
1175
+ last_activity_update = now
1176
+ estimated_output = output_tokens or max(0, len(text_so_far) // 4)
1177
+ write_router_activity(
1178
+ "request",
1179
+ provider,
1180
+ model,
1181
+ tokens=input_tokens,
1182
+ output_tokens=estimated_output,
1183
+ chunks=chunks_seen,
1184
+ stream=True,
1185
+ )
1186
+
1187
+ def handle_text_chunk(text_chunk: str) -> None:
1188
+ nonlocal text_buffer, text_so_far, text_suppressed_for_plan
1189
+ if not text_chunk:
1190
+ return
1191
+ if source_body is not None and not text_started and not tool_calls and should_auto_enter_plan_mode(source_body, text_so_far + text_chunk, []):
1192
+ text_so_far += text_chunk
1193
+ text_suppressed_for_plan = True
1194
+ return
1195
+ if text_suppressed_for_plan and not text_started and text_so_far:
1196
+ pending_text = text_so_far + text_chunk
1197
+ text_so_far = pending_text
1198
+ text_suppressed_for_plan = False
1199
+ active_text_index = open_text_block()
1200
+ if word_chunking:
1201
+ text_buffer += pending_text
1202
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=False)
1203
+ if to_flush:
1204
+ event = {
1205
+ "type": "content_block_delta",
1206
+ "index": active_text_index,
1207
+ "delta": {"type": "text_delta", "text": to_flush},
1208
+ }
1209
+ emit("content_block_delta", event)
1210
+ else:
1211
+ event = {
1212
+ "type": "content_block_delta",
1213
+ "index": active_text_index,
1214
+ "delta": {"type": "text_delta", "text": pending_text},
1215
+ }
1216
+ emit("content_block_delta", event)
1217
+ update_stream_activity()
1218
+ return
1219
+ active_text_index = open_text_block()
1220
+ text_so_far += text_chunk
1221
+ if word_chunking:
1222
+ text_buffer += text_chunk
1223
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=False)
1224
+ if to_flush:
1225
+ event = {
1226
+ "type": "content_block_delta",
1227
+ "index": active_text_index,
1228
+ "delta": {"type": "text_delta", "text": to_flush},
1229
+ }
1230
+ emit("content_block_delta", event)
1231
+ else:
1232
+ event = {
1233
+ "type": "content_block_delta",
1234
+ "index": active_text_index,
1235
+ "delta": {"type": "text_delta", "text": text_chunk},
1236
+ }
1237
+ emit("content_block_delta", event)
1238
+ update_stream_activity()
1239
+
1240
+ try:
1241
+ for line in iter_upstream_lines_until_client_disconnect(handler, resp, idle_timeout):
1242
+ chunks_seen += 1
1243
+ line = line.decode("utf-8", errors="ignore").strip()
1244
+ if not line:
1245
+ continue
1246
+ try:
1247
+ chunk = json.loads(line)
1248
+ except Exception:
1249
+ continue
1250
+ if not isinstance(chunk, dict):
1251
+ continue
1252
+ message = chunk.get("message") if isinstance(chunk.get("message"), dict) else {}
1253
+ input_tokens = max(input_tokens, int(chunk.get("prompt_eval_count") or 0))
1254
+ output_tokens = max(output_tokens, int(chunk.get("eval_count") or 0))
1255
+ if not started:
1256
+ ensure_message_started()
1257
+ # Handle text content
1258
+ raw_text_chunk = str(message.get("content") or "")
1259
+ text_chunk = thinking_markup_filter.feed(raw_text_chunk)
1260
+ if text_chunk != raw_text_chunk:
1261
+ thinking_markup_suppressed = True
1262
+ if text_chunk:
1263
+ handle_text_chunk(text_chunk)
1264
+ # Handle tool calls
1265
+ for call in message.get("tool_calls") or []:
1266
+ fn = call.get("function") if isinstance(call.get("function"), dict) else {}
1267
+ if not isinstance(fn, dict) or not fn.get("name"):
1268
+ continue
1269
+ raw_name = str(fn["name"])
1270
+ matched_name = resolve_emitted_tool_name(raw_name, source_body)
1271
+ raw_args = fn.get("arguments")
1272
+ normalized_args = normalize_tool_arguments(matched_name, raw_args)
1273
+ fixed_input = _validate_and_fix_tool_input(matched_name, normalized_args)
1274
+ if source_body is not None:
1275
+ matched_name, fixed_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
1276
+ if matched_name is None:
1277
+ continue
1278
+ fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
1279
+ if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body):
1280
+ continue
1281
+ if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
1282
+ continue
1283
+ close_text_block()
1284
+ tool_calls.append({"function": {"name": matched_name, "arguments": fixed_input}})
1285
+ tool_id = f"toolu_ollama_{int(time.time() * 1000)}_{len(tool_calls) - 1}"
1286
+ tool_index = next_content_index
1287
+ next_content_index += 1
1288
+ tool_indices.append(tool_index)
1289
+ _remember_channel_injected_tool_use(source_body, tool_id, matched_name, fixed_input)
1290
+ append_tool_call_log(
1291
+ "ollama_stream_tool_call",
1292
+ {
1293
+ "model": model,
1294
+ "raw_name": raw_name,
1295
+ "matched_name": matched_name,
1296
+ "raw_arguments": raw_args,
1297
+ "normalized_arguments": normalized_args,
1298
+ "emitted_input": fixed_input,
1299
+ "sse_index": tool_index,
1300
+ },
1301
+ )
1302
+ emit_tool_block(tool_index, tool_id, matched_name, fixed_input)
1303
+ update_stream_activity()
1304
+ update_stream_activity()
1305
+ trailing_text = thinking_markup_filter.finish()
1306
+ if trailing_text:
1307
+ handle_text_chunk(trailing_text)
1308
+ if thinking_markup_suppressed:
1309
+ router_log("WARN", f"suppressed visible Ollama thinking markup from stream model={model}")
1310
+ update_stream_activity(force=True)
1311
+ # Flush any remaining buffered text when word-chunking is active
1312
+ if source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
1313
+ ensure_message_started()
1314
+ close_text_block()
1315
+ router_log("WARN", "auto-synthesized EnterPlanMode from short/empty upstream stream")
1316
+ tool_calls.append({"function": {"name": "EnterPlanMode", "arguments": {}}})
1317
+ tool_id = f"toolu_ollama_plan_{int(time.time() * 1000)}"
1318
+ tool_index = next_content_index
1319
+ next_content_index += 1
1320
+ tool_indices.append(tool_index)
1321
+ emit_tool_block(tool_index, tool_id, "EnterPlanMode", {})
1322
+ elif source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
1323
+ ensure_message_started()
1324
+ close_text_block()
1325
+ router_log("WARN", "auto-synthesized TaskList from empty upstream end_turn stream")
1326
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1327
+ tool_id = f"toolu_ollama_empty_{int(time.time() * 1000)}"
1328
+ tool_index = next_content_index
1329
+ next_content_index += 1
1330
+ tool_indices.append(tool_index)
1331
+ emit_tool_block(tool_index, tool_id, "TaskList", {})
1332
+ elif text_suppressed_for_plan and not text_started and text_so_far:
1333
+ active_text_index = open_text_block()
1334
+ event = {
1335
+ "type": "content_block_delta",
1336
+ "index": active_text_index,
1337
+ "delta": {"type": "text_delta", "text": text_so_far},
1338
+ }
1339
+ emit("content_block_delta", event)
1340
+ if source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
1341
+ ensure_message_started()
1342
+ close_text_block()
1343
+ router_log("WARN", "auto-synthesized TaskList to keep work moving after tool result stream")
1344
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1345
+ tool_id = f"toolu_ollama_keepalive_{int(time.time() * 1000)}"
1346
+ tool_index = next_content_index
1347
+ next_content_index += 1
1348
+ tool_indices.append(tool_index)
1349
+ emit_tool_block(tool_index, tool_id, "TaskList", {})
1350
+ if source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
1351
+ ensure_message_started()
1352
+ close_text_block()
1353
+ router_log("WARN", "auto-synthesized TaskList after clarification question stream")
1354
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1355
+ tool_id = f"toolu_ollama_choice_{int(time.time() * 1000)}"
1356
+ tool_index = next_content_index
1357
+ next_content_index += 1
1358
+ tool_indices.append(tool_index)
1359
+ emit_tool_block(tool_index, tool_id, "TaskList", {})
1360
+ # Send content_block_stop for text if any
1361
+ close_text_block()
1362
+ # Send content_block_stop for each tool call
1363
+ for tool_index in tool_indices:
1364
+ if tool_index in stopped_tool_indices:
1365
+ continue
1366
+ event = {"type": "content_block_stop", "index": tool_index}
1367
+ emit("content_block_stop", event)
1368
+ stopped_tool_indices.add(tool_index)
1369
+ if not started:
1370
+ ensure_message_started()
1371
+ if not text_started and not tool_indices:
1372
+ router_log("WARN", f"ollama_empty_stream provider={provider} model={model} chunks={chunks_seen}")
1373
+ write_router_activity("error", provider, model, error="empty_stream", stream=True)
1374
+ empty_index = next_content_index
1375
+ next_content_index += 1
1376
+ notice = empty_end_turn_notice_for_body(source_body) if source_body is not None else ""
1377
+ if notice:
1378
+ text_so_far = notice
1379
+ emit_text_block(empty_index, notice)
1380
+ # Determine stop reason
1381
+ stop_reason = "tool_use" if tool_calls else "end_turn"
1382
+ if chunk.get("done_reason") == "length":
1383
+ stop_reason = "max_tokens"
1384
+ # Send message_delta with final stop_reason
1385
+ event = {
1386
+ "type": "message_delta",
1387
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
1388
+ "usage": {"output_tokens": output_tokens},
1389
+ }
1390
+ emit("message_delta", event)
1391
+ # Send message_stop
1392
+ emit("message_stop", {"type": "message_stop"})
1393
+ sse_trace_outcome = "success"
1394
+ if text_started or tool_indices:
1395
+ write_router_activity(
1396
+ "success",
1397
+ provider,
1398
+ model,
1399
+ tokens=input_tokens,
1400
+ output_tokens=output_tokens or max(1, len(text_so_far) // 4),
1401
+ chunks=chunks_seen,
1402
+ stream=True,
1403
+ )
1404
+ mark_pending_channel_delivery_success(handler, "ollama_stream_message_stop")
1405
+ except UpstreamClientDisconnected as exc:
1406
+ sse_trace_outcome = "client_disconnected"
1407
+ sse_trace_error = f"{type(exc).__name__}: {exc}"
1408
+ mark_pending_channel_delivery_failed(handler, "ollama_stream_client_disconnected")
1409
+ router_log(
1410
+ "WARN",
1411
+ f"ollama_stream_client_disconnected provider={provider} model={model} "
1412
+ f"chunks={chunks_seen} text_len={len(text_so_far)} error={exc}",
1413
+ )
1414
+ write_router_activity(
1415
+ "cancel",
1416
+ provider,
1417
+ model,
1418
+ error=type(exc).__name__,
1419
+ tokens=input_tokens,
1420
+ output_tokens=output_tokens or max(0, len(text_so_far) // 4),
1421
+ chunks=chunks_seen,
1422
+ stream=True,
1423
+ )
1424
+ except Exception as exc:
1425
+ sse_trace_outcome = "error"
1426
+ sse_trace_error = f"{type(exc).__name__}: {exc}"
1427
+ mark_pending_channel_delivery_failed(handler, f"ollama_stream_error:{type(exc).__name__}")
1428
+ router_log("ERROR", f"ollama_stream_error provider={provider} model={model} error={type(exc).__name__}: {exc}")
1429
+ write_router_activity("error", provider, model, error=type(exc).__name__, stream=True)
1430
+ try:
1431
+ ensure_message_started()
1432
+ if text_block_open:
1433
+ close_text_block()
1434
+ if not text_started and not tool_indices:
1435
+ error_index = next_content_index
1436
+ next_content_index += 1
1437
+ emit_text_block(error_index, f"Upstream stream error: {type(exc).__name__}: {exc}")
1438
+ for tool_index in tool_indices:
1439
+ if tool_index not in stopped_tool_indices:
1440
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1441
+ stopped_tool_indices.add(tool_index)
1442
+ emit(
1443
+ "message_delta",
1444
+ {
1445
+ "type": "message_delta",
1446
+ "delta": {"stop_reason": "end_turn", "stop_sequence": None},
1447
+ "usage": {"output_tokens": output_tokens or 1},
1448
+ },
1449
+ )
1450
+ emit("message_stop", {"type": "message_stop"})
1451
+ except Exception as exc:
1452
+ router_log("WARN", f"ollama_stream_fallback_emit_failed model={model} error={type(exc).__name__}: {exc}")
1453
+ finally:
1454
+ try:
1455
+ resp.close()
1456
+ except Exception as exc:
1457
+ router_log("WARN", f"ollama_stream_response_close_failed model={model} error={type(exc).__name__}: {exc}")
1458
+ try:
1459
+ final_stop_reason = locals().get("stop_reason")
1460
+ finish_outgoing_sse_trace(
1461
+ sse_trace,
1462
+ outcome=sse_trace_outcome,
1463
+ text_len=len(text_so_far),
1464
+ tool_call_count=len(tool_calls),
1465
+ chunks=chunks_seen,
1466
+ stop_reason=final_stop_reason if isinstance(final_stop_reason, str) else None,
1467
+ error=sse_trace_error,
1468
+ )
1469
+ dump_response_for_trace(
1470
+ provider=provider,
1471
+ model=model,
1472
+ text_so_far=text_so_far,
1473
+ tool_calls=tool_calls,
1474
+ stop_reason=final_stop_reason if isinstance(final_stop_reason, str) else None,
1475
+ input_tokens=input_tokens,
1476
+ output_tokens=output_tokens,
1477
+ last_chunk=chunk if isinstance(chunk, dict) else None,
1478
+ )
1479
+ except Exception as exc:
1480
+ router_log("WARN", f"ollama_stream_trace_finalize_failed model={model} error={type(exc).__name__}: {exc}")
1481
+
1482
+
1483
+ @dataclass(frozen=True, slots=True)
1484
+ class OpenAIChatStreamIO:
1485
+ PSEUDO_TOOL_END: str
1486
+ PSEUDO_TOOL_START: str
1487
+ _split_word_buffer: Callable[..., Any]
1488
+ positive_int: Callable[..., Any]
1489
+ router_log: Callable[..., Any]
1490
+ write_anthropic_open_stream_stop: Callable[..., Any]
1491
+ write_router_activity: Callable[..., Any]
1492
+
1493
+
1494
+ @dataclass(frozen=True, slots=True)
1495
+ class OpenAIChatToolProjection:
1496
+ _remember_channel_injected_tool_use: Callable[..., Any]
1497
+ _validate_and_fix_tool_input: Callable[..., Any]
1498
+ append_tool_call_log: Callable[..., Any]
1499
+ cap_mcp_notification_wait_tool_input: Callable[..., Any]
1500
+ normalize_tool_arguments: Callable[..., Any]
1501
+ parse_pseudo_tool_calls: Callable[..., Any]
1502
+ plan_mode_tool_name_for_emit: Callable[..., Any]
1503
+ resolve_emitted_tool_name: Callable[..., Any]
1504
+ should_drop_duplicate_side_effect_tool_call: Callable[..., Any]
1505
+ should_drop_emitted_tool_call: Callable[..., Any]
1506
+
1507
+
1508
+ @dataclass(frozen=True, slots=True)
1509
+ class OpenAIChatContinuationPolicy:
1510
+ empty_end_turn_notice_for_body: Callable[..., Any]
1511
+ latest_user_tool_result_names: Callable[..., Any]
1512
+ should_auto_continue_choice_question_with_tasklist: Callable[..., Any]
1513
+ should_auto_enter_plan_mode: Callable[..., Any]
1514
+ should_keep_work_alive_with_tasklist: Callable[..., Any]
1515
+ should_recover_empty_end_turn_with_tasklist: Callable[..., Any]
1516
+
1517
+
1518
+ @dataclass(frozen=True, slots=True)
1519
+ class OpenAIChatStreamServices:
1520
+ io: OpenAIChatStreamIO
1521
+ tool_projection: OpenAIChatToolProjection
1522
+ continuation: OpenAIChatContinuationPolicy
1523
+
1524
+
1525
+ def forward_openai_chat_to_anthropic_sse(
1526
+ handler: BaseHTTPRequestHandler,
1527
+ resp: Any,
1528
+ model: str,
1529
+ provider: str,
1530
+ source_body: dict[str, Any] | None = None,
1531
+ start_index: int = 0,
1532
+ word_chunking: bool = False,
1533
+ input_tokens: int | None = None,
1534
+ input_bytes: int | None = None,
1535
+ *,
1536
+ services: OpenAIChatStreamServices,
1537
+ ) -> bool:
1538
+
1539
+ PSEUDO_TOOL_END = services.io.PSEUDO_TOOL_END
1540
+ PSEUDO_TOOL_START = services.io.PSEUDO_TOOL_START
1541
+ _split_word_buffer = services.io._split_word_buffer
1542
+ positive_int = services.io.positive_int
1543
+ router_log = services.io.router_log
1544
+ write_anthropic_open_stream_stop = services.io.write_anthropic_open_stream_stop
1545
+ write_router_activity = services.io.write_router_activity
1546
+ _remember_channel_injected_tool_use = services.tool_projection._remember_channel_injected_tool_use
1547
+ _validate_and_fix_tool_input = services.tool_projection._validate_and_fix_tool_input
1548
+ append_tool_call_log = services.tool_projection.append_tool_call_log
1549
+ cap_mcp_notification_wait_tool_input = services.tool_projection.cap_mcp_notification_wait_tool_input
1550
+ normalize_tool_arguments = services.tool_projection.normalize_tool_arguments
1551
+ parse_pseudo_tool_calls = services.tool_projection.parse_pseudo_tool_calls
1552
+ plan_mode_tool_name_for_emit = services.tool_projection.plan_mode_tool_name_for_emit
1553
+ resolve_emitted_tool_name = services.tool_projection.resolve_emitted_tool_name
1554
+ should_drop_duplicate_side_effect_tool_call = services.tool_projection.should_drop_duplicate_side_effect_tool_call
1555
+ should_drop_emitted_tool_call = services.tool_projection.should_drop_emitted_tool_call
1556
+ empty_end_turn_notice_for_body = services.continuation.empty_end_turn_notice_for_body
1557
+ latest_user_tool_result_names = services.continuation.latest_user_tool_result_names
1558
+ should_auto_continue_choice_question_with_tasklist = services.continuation.should_auto_continue_choice_question_with_tasklist
1559
+ should_auto_enter_plan_mode = services.continuation.should_auto_enter_plan_mode
1560
+ should_keep_work_alive_with_tasklist = services.continuation.should_keep_work_alive_with_tasklist
1561
+ should_recover_empty_end_turn_with_tasklist = services.continuation.should_recover_empty_end_turn_with_tasklist
1562
+ next_content_index = start_index
1563
+ text_started = False
1564
+ text_suppressed_for_plan = False
1565
+ text_index: int | None = None
1566
+ text_so_far = ""
1567
+ pseudo_text = ""
1568
+ pseudo_mode = False
1569
+ text_buffer = ""
1570
+ text_stopped = False
1571
+ reasoning_started = False
1572
+ reasoning_stopped = False
1573
+ reasoning_index: int | None = None
1574
+ reasoning_so_far = ""
1575
+ tool_fragments: dict[int, dict[str, Any]] = {}
1576
+ output_tokens = 0
1577
+ finish_reason = "stop"
1578
+ chunks_seen = 0
1579
+ last_activity_update = 0.0
1580
+
1581
+ def emit(event_name: str, payload: dict[str, Any]) -> None:
1582
+ handler.wfile.write(f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode())
1583
+ handler.wfile.flush()
1584
+
1585
+ def ensure_text_started() -> int:
1586
+ nonlocal text_started, text_index, next_content_index, text_stopped
1587
+ if text_started and text_index is not None:
1588
+ return text_index
1589
+ text_started = True
1590
+ text_stopped = False
1591
+ text_index = next_content_index
1592
+ next_content_index += 1
1593
+ emit(
1594
+ "content_block_start",
1595
+ {"type": "content_block_start", "index": text_index, "content_block": {"type": "text", "text": ""}},
1596
+ )
1597
+ return text_index
1598
+
1599
+ def ensure_reasoning_started() -> int:
1600
+ nonlocal reasoning_started, reasoning_index, next_content_index, reasoning_stopped
1601
+ if reasoning_started and reasoning_index is not None:
1602
+ return reasoning_index
1603
+ reasoning_started = True
1604
+ reasoning_stopped = False
1605
+ reasoning_index = next_content_index
1606
+ next_content_index += 1
1607
+ emit(
1608
+ "content_block_start",
1609
+ {
1610
+ "type": "content_block_start",
1611
+ "index": reasoning_index,
1612
+ "content_block": {"type": "thinking", "thinking": ""},
1613
+ },
1614
+ )
1615
+ return reasoning_index
1616
+
1617
+ def emit_reasoning_delta(text: str) -> None:
1618
+ if not text:
1619
+ return
1620
+ idx = ensure_reasoning_started()
1621
+ emit(
1622
+ "content_block_delta",
1623
+ {"type": "content_block_delta", "index": idx, "delta": {"type": "thinking_delta", "thinking": text}},
1624
+ )
1625
+
1626
+ def close_reasoning_block() -> None:
1627
+ nonlocal reasoning_stopped
1628
+ if not reasoning_started or reasoning_index is None or reasoning_stopped:
1629
+ return
1630
+ digest = hashlib.sha256(reasoning_so_far.encode("utf-8", errors="replace")).hexdigest()[:24]
1631
+ emit(
1632
+ "content_block_delta",
1633
+ {
1634
+ "type": "content_block_delta",
1635
+ "index": reasoning_index,
1636
+ "delta": {
1637
+ "type": "signature_delta",
1638
+ "signature": f"ciel-runtime-openai-reasoning-{digest}",
1639
+ },
1640
+ },
1641
+ )
1642
+ emit("content_block_stop", {"type": "content_block_stop", "index": reasoning_index})
1643
+ reasoning_stopped = True
1644
+
1645
+ def emit_text_delta(text: str) -> None:
1646
+ if not text:
1647
+ return
1648
+ idx = ensure_text_started()
1649
+ emit(
1650
+ "content_block_delta",
1651
+ {"type": "content_block_delta", "index": idx, "delta": {"type": "text_delta", "text": text}},
1652
+ )
1653
+
1654
+ def update_stream_activity(force: bool = False) -> None:
1655
+ nonlocal last_activity_update
1656
+ now = time.time()
1657
+ if not force and now - last_activity_update < 0.5:
1658
+ return
1659
+ last_activity_update = now
1660
+ estimated_output = output_tokens or max(0, len(text_so_far) // 4)
1661
+ write_router_activity(
1662
+ "request",
1663
+ provider,
1664
+ model,
1665
+ tokens=input_tokens,
1666
+ bytes=input_bytes,
1667
+ output_tokens=estimated_output,
1668
+ chunks=chunks_seen,
1669
+ stream=True,
1670
+ )
1671
+
1672
+ try:
1673
+ for raw_line in resp:
1674
+ chunks_seen += 1
1675
+ line = raw_line.decode("utf-8", errors="ignore").strip()
1676
+ if not line or line.startswith(":"):
1677
+ continue
1678
+ if line.startswith("data:"):
1679
+ line = line[5:].strip()
1680
+ if not line or line == "[DONE]":
1681
+ break
1682
+ try:
1683
+ event = json.loads(line)
1684
+ except Exception:
1685
+ continue
1686
+ if not isinstance(event, dict):
1687
+ continue
1688
+ usage = event.get("usage")
1689
+ if isinstance(usage, dict):
1690
+ output_tokens = max(output_tokens, positive_int(usage.get("completion_tokens")) or 0)
1691
+ choices = event.get("choices")
1692
+ if not isinstance(choices, list) or not choices:
1693
+ continue
1694
+ choice = choices[0] if isinstance(choices[0], dict) else {}
1695
+ if choice.get("finish_reason"):
1696
+ finish_reason = str(choice.get("finish_reason"))
1697
+ delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
1698
+ reasoning_chunk = delta.get("reasoning_content") or ""
1699
+ if reasoning_chunk:
1700
+ reasoning_so_far += str(reasoning_chunk)
1701
+ emit_reasoning_delta(str(reasoning_chunk))
1702
+ update_stream_activity()
1703
+ text_chunk = delta.get("content") or ""
1704
+ if text_chunk:
1705
+ close_reasoning_block()
1706
+ if pseudo_mode or PSEUDO_TOOL_START in text_chunk:
1707
+ before, sep, after = text_chunk.partition(PSEUDO_TOOL_START)
1708
+ if before and not pseudo_mode:
1709
+ text_so_far += before
1710
+ if word_chunking:
1711
+ text_buffer += before
1712
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=False)
1713
+ emit_text_delta(to_flush)
1714
+ else:
1715
+ emit_text_delta(before)
1716
+ pseudo_mode = True
1717
+ pseudo_text += (sep + after) if sep else text_chunk
1718
+ if PSEUDO_TOOL_END in pseudo_text:
1719
+ pseudo_mode = False
1720
+ continue
1721
+ if source_body is not None and not text_started and not tool_fragments and should_auto_enter_plan_mode(source_body, text_so_far + text_chunk, []):
1722
+ text_so_far += text_chunk
1723
+ text_suppressed_for_plan = True
1724
+ continue
1725
+ if text_suppressed_for_plan and not text_started and text_so_far:
1726
+ pending_text = text_so_far + text_chunk
1727
+ text_so_far = pending_text
1728
+ text_suppressed_for_plan = False
1729
+ if word_chunking:
1730
+ text_buffer += pending_text
1731
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=False)
1732
+ emit_text_delta(to_flush)
1733
+ else:
1734
+ emit_text_delta(pending_text)
1735
+ update_stream_activity()
1736
+ continue
1737
+ text_so_far += text_chunk
1738
+ if word_chunking:
1739
+ text_buffer += text_chunk
1740
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=False)
1741
+ emit_text_delta(to_flush)
1742
+ else:
1743
+ emit_text_delta(text_chunk)
1744
+ update_stream_activity()
1745
+ for call in delta.get("tool_calls") or []:
1746
+ if not isinstance(call, dict):
1747
+ continue
1748
+ try:
1749
+ call_index = int(call.get("index"))
1750
+ except Exception:
1751
+ call_index = len(tool_fragments)
1752
+ slot = tool_fragments.setdefault(call_index, {"id": "", "name": "", "arguments": ""})
1753
+ if call.get("id"):
1754
+ slot["id"] = str(call.get("id"))
1755
+ fn = call.get("function") if isinstance(call.get("function"), dict) else {}
1756
+ if fn.get("name"):
1757
+ slot["name"] += str(fn.get("name"))
1758
+ if fn.get("arguments"):
1759
+ slot["arguments"] += str(fn.get("arguments"))
1760
+ update_stream_activity()
1761
+ update_stream_activity(force=True)
1762
+ if word_chunking and text_buffer:
1763
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=True)
1764
+ emit_text_delta(to_flush)
1765
+ close_reasoning_block()
1766
+
1767
+ tool_calls: list[dict[str, Any]] = []
1768
+ _, pseudo_tool_calls = parse_pseudo_tool_calls(pseudo_text, source_body)
1769
+ for i, pseudo in enumerate(pseudo_tool_calls):
1770
+ fn = pseudo.get("function") if isinstance(pseudo, dict) else {}
1771
+ if isinstance(fn, dict):
1772
+ tool_fragments.setdefault(100000 + i, {
1773
+ "id": str(pseudo.get("id") or ""),
1774
+ "name": str(fn.get("name") or ""),
1775
+ "arguments": json.dumps(fn.get("arguments") or {}, ensure_ascii=False),
1776
+ })
1777
+ for _, fragment in sorted(tool_fragments.items()):
1778
+ raw_name = str(fragment.get("name") or "")
1779
+ if not raw_name:
1780
+ continue
1781
+ matched_name = resolve_emitted_tool_name(raw_name, source_body)
1782
+ normalized_args = normalize_tool_arguments(matched_name, fragment.get("arguments") or {})
1783
+ fixed_input = _validate_and_fix_tool_input(matched_name, normalized_args)
1784
+ if source_body is not None:
1785
+ matched_name, fixed_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
1786
+ if matched_name is None:
1787
+ continue
1788
+ fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
1789
+ if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body):
1790
+ continue
1791
+ if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
1792
+ continue
1793
+ tool_calls.append({"function": {"name": matched_name, "arguments": fixed_input}})
1794
+ tool_index = next_content_index
1795
+ next_content_index += 1
1796
+ tool_id = str(fragment.get("id") or f"toolu_openai_{int(time.time() * 1000)}_{tool_index}")
1797
+ _remember_channel_injected_tool_use(source_body, tool_id, matched_name, fixed_input)
1798
+ append_tool_call_log(
1799
+ "openai_stream_tool_call",
1800
+ {
1801
+ "model": model,
1802
+ "raw_name": raw_name,
1803
+ "matched_name": matched_name,
1804
+ "raw_arguments": fragment.get("arguments"),
1805
+ "emitted_input": fixed_input,
1806
+ "sse_index": tool_index,
1807
+ },
1808
+ )
1809
+ emit(
1810
+ "content_block_start",
1811
+ {
1812
+ "type": "content_block_start",
1813
+ "index": tool_index,
1814
+ "content_block": {"type": "tool_use", "id": tool_id, "name": matched_name, "input": {}},
1815
+ },
1816
+ )
1817
+ emit(
1818
+ "content_block_delta",
1819
+ {
1820
+ "type": "content_block_delta",
1821
+ "index": tool_index,
1822
+ "delta": {"type": "input_json_delta", "partial_json": json.dumps(fixed_input, ensure_ascii=False)},
1823
+ },
1824
+ )
1825
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1826
+
1827
+ if source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
1828
+ router_log("WARN", "auto-synthesized EnterPlanMode from short/empty upstream OpenAI stream")
1829
+ tool_index = next_content_index
1830
+ next_content_index += 1
1831
+ tool_calls.append({"function": {"name": "EnterPlanMode", "arguments": {}}})
1832
+ emit(
1833
+ "content_block_start",
1834
+ {
1835
+ "type": "content_block_start",
1836
+ "index": tool_index,
1837
+ "content_block": {"type": "tool_use", "id": f"toolu_openai_plan_{int(time.time() * 1000)}", "name": "EnterPlanMode", "input": {}},
1838
+ },
1839
+ )
1840
+ emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1841
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1842
+ elif source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
1843
+ router_log("WARN", "auto-synthesized TaskList from empty upstream end_turn OpenAI stream")
1844
+ tool_index = next_content_index
1845
+ next_content_index += 1
1846
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1847
+ emit(
1848
+ "content_block_start",
1849
+ {
1850
+ "type": "content_block_start",
1851
+ "index": tool_index,
1852
+ "content_block": {"type": "tool_use", "id": f"toolu_openai_empty_{int(time.time() * 1000)}", "name": "TaskList", "input": {}},
1853
+ },
1854
+ )
1855
+ emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1856
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1857
+ elif text_suppressed_for_plan and not text_started and text_so_far:
1858
+ emit_text_delta(text_so_far)
1859
+
1860
+ if source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
1861
+ router_log("WARN", "auto-synthesized TaskList to keep work moving after OpenAI stream")
1862
+ tool_index = next_content_index
1863
+ next_content_index += 1
1864
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1865
+ emit(
1866
+ "content_block_start",
1867
+ {
1868
+ "type": "content_block_start",
1869
+ "index": tool_index,
1870
+ "content_block": {"type": "tool_use", "id": f"toolu_openai_keepalive_{int(time.time() * 1000)}", "name": "TaskList", "input": {}},
1871
+ },
1872
+ )
1873
+ emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1874
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1875
+
1876
+ if source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
1877
+ router_log("WARN", "auto-synthesized TaskList after clarification question OpenAI stream")
1878
+ tool_index = next_content_index
1879
+ next_content_index += 1
1880
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1881
+ emit(
1882
+ "content_block_start",
1883
+ {
1884
+ "type": "content_block_start",
1885
+ "index": tool_index,
1886
+ "content_block": {"type": "tool_use", "id": f"toolu_openai_choice_{int(time.time() * 1000)}", "name": "TaskList", "input": {}},
1887
+ },
1888
+ )
1889
+ emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1890
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1891
+
1892
+ if text_started and text_index is not None:
1893
+ emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1894
+ text_stopped = True
1895
+ if not text_started and not tool_calls:
1896
+ text_so_far = empty_end_turn_notice_for_body(source_body) if source_body is not None else ""
1897
+ if source_body is not None:
1898
+ router_log(
1899
+ "WARN",
1900
+ f"openai_empty_end_turn_notice provider={provider} model={model} "
1901
+ f"latest_tool_results={','.join(latest_user_tool_result_names(source_body)) or '-'}",
1902
+ )
1903
+ emit_text_delta(text_so_far)
1904
+ if text_index is not None:
1905
+ emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1906
+ text_stopped = True
1907
+ stop_reason = "tool_use" if tool_calls else ("max_tokens" if finish_reason == "length" else "end_turn")
1908
+ write_anthropic_open_stream_stop(handler, {"stop_reason": stop_reason, "usage": {"output_tokens": output_tokens or max(1, len(text_so_far) // 4)}})
1909
+ return True
1910
+ except Exception as exc:
1911
+ router_log("ERROR", f"openai_stream_error provider={provider} model={model} error={type(exc).__name__}: {exc}")
1912
+ write_router_activity("error", provider, model, error=type(exc).__name__, stream=True)
1913
+ try:
1914
+ if word_chunking and text_buffer:
1915
+ to_flush, text_buffer = _split_word_buffer(text_buffer, force=True)
1916
+ emit_text_delta(to_flush)
1917
+ if not text_started:
1918
+ emit_text_delta(f"Upstream stream error: {type(exc).__name__}: {exc}")
1919
+ if text_started and text_index is not None and not text_stopped:
1920
+ emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1921
+ text_stopped = True
1922
+ write_anthropic_open_stream_stop(
1923
+ handler,
1924
+ {"stop_reason": "end_turn", "usage": {"output_tokens": output_tokens or max(1, len(text_so_far) // 4)}},
1925
+ )
1926
+ except Exception as exc:
1927
+ router_log("WARN", f"openai_stream_fallback_emit_failed provider={provider} model={model} error={type(exc).__name__}: {exc}")
1928
+ return False
1929
+ finally:
1930
+ try:
1931
+ resp.close()
1932
+ except Exception as exc:
1933
+ router_log("WARN", f"openai_stream_response_close_failed provider={provider} model={model} error={type(exc).__name__}: {exc}")
1934
+
1935
+
1936
+ __all__ = [
1937
+ "AnthropicContinuationPolicy",
1938
+ "AnthropicConversationContext",
1939
+ "AnthropicStreamIO",
1940
+ "AnthropicStreamServices",
1941
+ "AnthropicToolPolicy",
1942
+ "AnthropicToolProjection",
1943
+ "OllamaContinuationPolicy",
1944
+ "OllamaStreamIO",
1945
+ "OllamaStreamServices",
1946
+ "OllamaStreamTrace",
1947
+ "OllamaToolProjection",
1948
+ "OpenAIChatContinuationPolicy",
1949
+ "OpenAIChatStreamIO",
1950
+ "OpenAIChatStreamServices",
1951
+ "OpenAIChatToolProjection",
1952
+ "forward_openai_chat_to_anthropic_sse",
1953
+ "ollama_stream_to_anthropic_sse",
1954
+ "rebatch_anthropic_sse_text",
1955
+ ]