@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.
- package/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- 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
|
+
]
|