@oneciel-ai/ciel-runtime 0.2.2 → 0.2.3
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/ciel_runtime.py +2553 -9635
- package/ciel_runtime_support/advisor_request_builder.py +8 -21
- package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
- package/ciel_runtime_support/architecture.py +68 -0
- package/ciel_runtime_support/architecture_budget.py +1 -1
- package/ciel_runtime_support/channel_connection_context.py +233 -0
- package/ciel_runtime_support/channel_delivery_context.py +332 -0
- package/ciel_runtime_support/channel_mcp_context.py +313 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
- package/ciel_runtime_support/channel_mcp_transport.py +5 -1
- package/ciel_runtime_support/channel_message_context.py +212 -0
- package/ciel_runtime_support/channel_message_repository.py +14 -3
- package/ciel_runtime_support/channel_pending_injection.py +9 -0
- package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
- package/ciel_runtime_support/channel_replay_policy.py +38 -0
- package/ciel_runtime_support/channel_runtime_environment.py +8 -0
- package/ciel_runtime_support/channel_session_context.py +236 -0
- package/ciel_runtime_support/channel_terminal_context.py +350 -0
- package/ciel_runtime_support/channel_wake_context.py +532 -0
- package/ciel_runtime_support/claude_environment.py +60 -0
- package/ciel_runtime_support/claude_launch_assembly.py +249 -0
- package/ciel_runtime_support/claude_router.py +62 -12
- package/ciel_runtime_support/cli_application_context.py +132 -0
- package/ciel_runtime_support/cli_assembly.py +50 -0
- package/ciel_runtime_support/codex_backend_context.py +363 -0
- package/ciel_runtime_support/codex_config.py +13 -1
- package/ciel_runtime_support/codex_launch_assembly.py +213 -0
- package/ciel_runtime_support/codex_launch_configuration.py +30 -1
- package/ciel_runtime_support/codex_mcp_integration.py +90 -8
- package/ciel_runtime_support/codex_model_catalog.py +4 -1
- package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
- package/ciel_runtime_support/codex_router.py +38 -8
- package/ciel_runtime_support/codex_turn_recovery.py +154 -0
- package/ciel_runtime_support/config_migrations.py +103 -0
- package/ciel_runtime_support/configuration_cli.py +38 -0
- package/ciel_runtime_support/context_compaction.py +9 -4
- package/ciel_runtime_support/credential_management.py +12 -0
- package/ciel_runtime_support/credentials.py +12 -0
- package/ciel_runtime_support/github_copilot_oauth.py +2 -2
- package/ciel_runtime_support/hosted_formula_tools.py +216 -0
- package/ciel_runtime_support/kimi_runtime_context.py +208 -0
- package/ciel_runtime_support/llm_preset_context.py +338 -0
- package/ciel_runtime_support/managed_mcp_config.py +8 -4
- package/ciel_runtime_support/mcp_configuration_context.py +291 -0
- package/ciel_runtime_support/mcp_http_proxy.py +14 -8
- package/ciel_runtime_support/mcp_probe_transport.py +47 -15
- package/ciel_runtime_support/mcp_transport.py +14 -1
- package/ciel_runtime_support/native_context_recovery.py +72 -0
- package/ciel_runtime_support/ollama_catalog_context.py +213 -0
- package/ciel_runtime_support/ollama_stream_collection.py +103 -0
- package/ciel_runtime_support/ollama_thinking.py +6 -1
- package/ciel_runtime_support/ollama_wire_projection.py +157 -0
- package/ciel_runtime_support/openai_forwarding.py +32 -10
- package/ciel_runtime_support/openai_responses_router.py +12 -0
- package/ciel_runtime_support/package_lifecycle.py +39 -0
- package/ciel_runtime_support/prelaunch_assembly.py +37 -0
- package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
- package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
- package/ciel_runtime_support/prompt_compaction.py +144 -0
- package/ciel_runtime_support/prompt_injection.py +45 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
- package/ciel_runtime_support/protocols/chat_projection.py +85 -5
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
- package/ciel_runtime_support/protocols/ollama_response.py +57 -5
- package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
- package/ciel_runtime_support/protocols/openai_responses.py +61 -15
- package/ciel_runtime_support/provider_adapters.py +26 -0
- package/ciel_runtime_support/provider_administration_context.py +207 -0
- package/ciel_runtime_support/provider_config_mutations.py +3 -0
- package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
- package/ciel_runtime_support/provider_model_context.py +107 -0
- package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
- package/ciel_runtime_support/provider_model_selection.py +10 -3
- package/ciel_runtime_support/provider_models.py +45 -2
- package/ciel_runtime_support/provider_option_cli.py +19 -0
- package/ciel_runtime_support/provider_policy.py +1 -1
- package/ciel_runtime_support/provider_readiness_context.py +189 -0
- package/ciel_runtime_support/provider_request_builder.py +64 -28
- package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
- package/ciel_runtime_support/provider_timeout_policy.py +54 -0
- package/ciel_runtime_support/provider_tool_policy.py +9 -1
- package/ciel_runtime_support/providers/__init__.py +6 -0
- package/ciel_runtime_support/providers/alibaba.py +634 -0
- package/ciel_runtime_support/providers/catalog.py +24 -16
- package/ciel_runtime_support/providers/deepseek.py +73 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
- package/ciel_runtime_support/providers/kimi.py +69 -9
- package/ciel_runtime_support/providers/ollama.py +8 -0
- package/ciel_runtime_support/providers/ollama_context.py +21 -2
- package/ciel_runtime_support/providers/vllm.py +7 -1
- package/ciel_runtime_support/response_collection.py +68 -18
- package/ciel_runtime_support/response_collection_context.py +391 -0
- package/ciel_runtime_support/response_stream_context.py +555 -0
- package/ciel_runtime_support/responses_input_compatibility.py +121 -0
- package/ciel_runtime_support/responses_usage_observer.py +83 -0
- package/ciel_runtime_support/router_client_lifecycle.py +1 -0
- package/ciel_runtime_support/router_http.py +239 -3
- package/ciel_runtime_support/router_observability_context.py +251 -0
- package/ciel_runtime_support/router_process_context.py +200 -0
- package/ciel_runtime_support/router_process_lifecycle.py +2 -0
- package/ciel_runtime_support/router_request_assembly.py +399 -0
- package/ciel_runtime_support/router_request_context.py +215 -0
- package/ciel_runtime_support/router_server_context.py +82 -0
- package/ciel_runtime_support/runaway_output_guard.py +488 -0
- package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
- package/ciel_runtime_support/runtime_asset_context.py +297 -0
- package/ciel_runtime_support/runtime_constants.py +16 -1
- package/ciel_runtime_support/runtime_launch.py +9 -5
- package/ciel_runtime_support/runtime_launch_context.py +130 -0
- package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
- package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
- package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
- package/ciel_runtime_support/runtime_paths.py +60 -40
- package/ciel_runtime_support/runtime_primitives.py +78 -0
- package/ciel_runtime_support/sse_stream_collection.py +236 -0
- package/ciel_runtime_support/statusline_script.py +57 -8
- package/ciel_runtime_support/streaming_anthropic.py +361 -24
- package/ciel_runtime_support/tool_schema.py +40 -2
- package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
- package/ciel_runtime_support/upstream_dump.py +68 -0
- package/ciel_runtime_support/upstream_retry_context.py +259 -0
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- package/package.json +1 -1
|
@@ -10,6 +10,14 @@ from dataclasses import dataclass
|
|
|
10
10
|
from http.server import BaseHTTPRequestHandler
|
|
11
11
|
from typing import Any, Callable, Iterable
|
|
12
12
|
|
|
13
|
+
from .runaway_output_guard import (
|
|
14
|
+
STOPPED,
|
|
15
|
+
RunawayOutputDetector,
|
|
16
|
+
RunawayVerdict,
|
|
17
|
+
policy_from_env,
|
|
18
|
+
recent_runaway_notices,
|
|
19
|
+
)
|
|
20
|
+
|
|
13
21
|
|
|
14
22
|
@dataclass(frozen=True, slots=True)
|
|
15
23
|
class AnthropicStreamIO:
|
|
@@ -157,10 +165,19 @@ def rebatch_anthropic_sse_text(
|
|
|
157
165
|
and (has_tool(source_body, "Workflow") or body_ultracode_runtime_enabled(source_body))
|
|
158
166
|
)
|
|
159
167
|
visible_tool_call_artifact_filters: dict[int, VisibleToolCallArtifactFilter] = {}
|
|
168
|
+
runaway_policy = policy_from_env(os.environ.get)
|
|
169
|
+
text_runaway = RunawayOutputDetector(runaway_policy)
|
|
170
|
+
thinking_runaway = RunawayOutputDetector(runaway_policy)
|
|
171
|
+
runaway_verdict: RunawayVerdict | None = None
|
|
160
172
|
|
|
161
173
|
class ClientStreamDisconnected(Exception):
|
|
162
174
|
pass
|
|
163
175
|
|
|
176
|
+
def note_runaway(detector: RunawayOutputDetector, text: str) -> None:
|
|
177
|
+
nonlocal runaway_verdict
|
|
178
|
+
if runaway_verdict is None:
|
|
179
|
+
runaway_verdict = detector.feed(text)
|
|
180
|
+
|
|
164
181
|
def downstream_keepalive_interval() -> float:
|
|
165
182
|
raw = os.environ.get("CIEL_RUNTIME_ANTHROPIC_STREAM_KEEPALIVE_SECONDS")
|
|
166
183
|
if raw is None:
|
|
@@ -430,6 +447,44 @@ def rebatch_anthropic_sse_text(
|
|
|
430
447
|
pending_message_stop[1] if pending_message_stop is not None else "{\"type\":\"message_stop\"}",
|
|
431
448
|
)
|
|
432
449
|
|
|
450
|
+
def emit_runaway_stop() -> None:
|
|
451
|
+
"""Close a looping turn instead of relaying the rest of the loop."""
|
|
452
|
+
|
|
453
|
+
nonlocal next_content_index
|
|
454
|
+
if runaway_verdict is None:
|
|
455
|
+
return
|
|
456
|
+
router_log(
|
|
457
|
+
"WARN",
|
|
458
|
+
f"anthropic_stream_runaway_repetition provider={provider} model={model} "
|
|
459
|
+
f"text_len={len(text_so_far)} {runaway_verdict.log_fields()}",
|
|
460
|
+
)
|
|
461
|
+
for index in sorted(open_content_blocks):
|
|
462
|
+
finish_visible_tool_call_artifact_filter(index)
|
|
463
|
+
emit_raw(
|
|
464
|
+
"content_block_stop",
|
|
465
|
+
json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False),
|
|
466
|
+
)
|
|
467
|
+
open_content_blocks.clear()
|
|
468
|
+
notice_index = next_content_index
|
|
469
|
+
next_content_index += 1
|
|
470
|
+
emit_text_block(notice_index, runaway_verdict.notice(STOPPED))
|
|
471
|
+
stop_reason = "max_tokens"
|
|
472
|
+
if (
|
|
473
|
+
not emitted_tool_use
|
|
474
|
+
and runaway_policy.recover
|
|
475
|
+
and recent_runaway_notices(source_body) == 0
|
|
476
|
+
):
|
|
477
|
+
# Cutting the loop should not cost the user the turn. A tool call
|
|
478
|
+
# brings the CLI back for a fresh one; the history check above stops
|
|
479
|
+
# the recovery from becoming a loop of its own.
|
|
480
|
+
router_log("WARN", f"anthropic_stream_runaway_recovery provider={provider} model={model}")
|
|
481
|
+
tool_index = next_content_index
|
|
482
|
+
next_content_index += 1
|
|
483
|
+
emit_tasklist_tool(tool_index)
|
|
484
|
+
stop_reason = "tool_use"
|
|
485
|
+
emit_raw("message_delta", patched_message_delta(stop_reason))
|
|
486
|
+
emit_raw("message_stop", "{\"type\":\"message_stop\"}")
|
|
487
|
+
|
|
433
488
|
def recover_hidden_only_response_if_needed() -> None:
|
|
434
489
|
nonlocal next_content_index, saw_tool_use, emitted_tool_use, text_so_far, pending_message_delta
|
|
435
490
|
recovery_reason = ""
|
|
@@ -517,6 +572,7 @@ def rebatch_anthropic_sse_text(
|
|
|
517
572
|
matched_name = resolve_emitted_tool_name(raw_name, source_body)
|
|
518
573
|
if not matched_name:
|
|
519
574
|
matched_name = infer_tool_name_from_args(parsed_args)
|
|
575
|
+
emitted_name = matched_name
|
|
520
576
|
fixed_input = _validate_and_fix_tool_input(matched_name, parsed_args, source_body)
|
|
521
577
|
if isinstance(source_body, dict):
|
|
522
578
|
mapped_name, mapped_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
|
|
@@ -528,9 +584,12 @@ def rebatch_anthropic_sse_text(
|
|
|
528
584
|
return
|
|
529
585
|
matched_name, fixed_input = mapped_name, mapped_input
|
|
530
586
|
fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
|
|
531
|
-
|
|
587
|
+
# Plan mode can retarget the call at a different tool, and then the
|
|
588
|
+
# original arguments no longer describe the schema being checked.
|
|
589
|
+
supplied_input = parsed_args if matched_name == emitted_name else None
|
|
590
|
+
if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body, supplied_input):
|
|
532
591
|
return
|
|
533
|
-
if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
|
|
592
|
+
if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name, source_body):
|
|
534
593
|
return
|
|
535
594
|
tool_id = str(tool_state.get("id") or f"toolu_anthropic_{int(time.time() * 1000)}_{index}")
|
|
536
595
|
_remember_channel_injected_tool_use(source_body, tool_id, matched_name, fixed_input)
|
|
@@ -820,11 +879,14 @@ def rebatch_anthropic_sse_text(
|
|
|
820
879
|
patched["index"] = mapped_index
|
|
821
880
|
event = patched
|
|
822
881
|
data_str = json.dumps(event, ensure_ascii=False)
|
|
882
|
+
if delta.get("type") == "thinking_delta":
|
|
883
|
+
note_runaway(thinking_runaway, str(delta.get("thinking") or ""))
|
|
823
884
|
if isinstance(mapped_index, int) and delta.get("type") == "text_delta":
|
|
824
885
|
text = delta.get("text") or ""
|
|
825
886
|
if not text:
|
|
826
887
|
return
|
|
827
888
|
text_so_far += text
|
|
889
|
+
note_runaway(text_runaway, text)
|
|
828
890
|
if provider != "anthropic" and mapped_index in held_pseudo_tool_text:
|
|
829
891
|
held_pseudo_tool_text[mapped_index] += text
|
|
830
892
|
return
|
|
@@ -882,6 +944,10 @@ def rebatch_anthropic_sse_text(
|
|
|
882
944
|
process_event(pending_event_type, data_str)
|
|
883
945
|
pending_event_type = None
|
|
884
946
|
pending_event_lines = []
|
|
947
|
+
if runaway_verdict is not None:
|
|
948
|
+
# Stop reading upstream; the finally block closes the
|
|
949
|
+
# response so the loop stops being generated and billed.
|
|
950
|
+
break
|
|
885
951
|
continue
|
|
886
952
|
if stripped.startswith("event:"):
|
|
887
953
|
pending_event_type = stripped[len("event:"):].strip() or None
|
|
@@ -896,6 +962,10 @@ def rebatch_anthropic_sse_text(
|
|
|
896
962
|
flush_buffer(index, force=True)
|
|
897
963
|
for index in list(suppressed_thinking_blocks.keys()):
|
|
898
964
|
finish_suppressed_thinking_block(index)
|
|
965
|
+
if runaway_verdict is not None:
|
|
966
|
+
emit_runaway_stop()
|
|
967
|
+
stream_success = True
|
|
968
|
+
return
|
|
899
969
|
recover_hidden_only_response_if_needed()
|
|
900
970
|
flush_suppressed_thinking_passback()
|
|
901
971
|
if pending_message_delta is not None or pending_message_stop is not None:
|
|
@@ -1007,6 +1077,7 @@ class OllamaToolProjection:
|
|
|
1007
1077
|
@dataclass(frozen=True, slots=True)
|
|
1008
1078
|
class OllamaContinuationPolicy:
|
|
1009
1079
|
empty_end_turn_notice_for_body: Callable[..., Any]
|
|
1080
|
+
reasoning_only_notice: Callable[..., Any]
|
|
1010
1081
|
should_auto_continue_choice_question_with_tasklist: Callable[..., Any]
|
|
1011
1082
|
should_auto_enter_plan_mode: Callable[..., Any]
|
|
1012
1083
|
should_keep_work_alive_with_tasklist: Callable[..., Any]
|
|
@@ -1057,6 +1128,7 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1057
1128
|
should_drop_duplicate_side_effect_tool_call = services.tool_projection.should_drop_duplicate_side_effect_tool_call
|
|
1058
1129
|
should_drop_emitted_tool_call = services.tool_projection.should_drop_emitted_tool_call
|
|
1059
1130
|
empty_end_turn_notice_for_body = services.continuation.empty_end_turn_notice_for_body
|
|
1131
|
+
reasoning_only_notice = services.continuation.reasoning_only_notice
|
|
1060
1132
|
should_auto_continue_choice_question_with_tasklist = services.continuation.should_auto_continue_choice_question_with_tasklist
|
|
1061
1133
|
should_auto_enter_plan_mode = services.continuation.should_auto_enter_plan_mode
|
|
1062
1134
|
should_keep_work_alive_with_tasklist = services.continuation.should_keep_work_alive_with_tasklist
|
|
@@ -1068,11 +1140,16 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1068
1140
|
handler.end_headers()
|
|
1069
1141
|
msg_id = f"msg_ollama_{int(time.time() * 1000)}"
|
|
1070
1142
|
started = False
|
|
1143
|
+
thinking_started = False
|
|
1071
1144
|
text_started = False
|
|
1072
1145
|
text_suppressed_for_plan = False
|
|
1073
1146
|
next_content_index = 0
|
|
1074
1147
|
text_index: int | None = None
|
|
1075
1148
|
text_block_open = False
|
|
1149
|
+
thinking_index: int | None = None
|
|
1150
|
+
thinking_block_open = False
|
|
1151
|
+
thinking_block_text = ""
|
|
1152
|
+
thinking_so_far = ""
|
|
1076
1153
|
text_so_far = ""
|
|
1077
1154
|
text_buffer = ""
|
|
1078
1155
|
tool_calls: list[dict[str, Any]] = []
|
|
@@ -1085,6 +1162,11 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1085
1162
|
last_activity_update = 0.0
|
|
1086
1163
|
thinking_markup_filter = VisibleThinkingMarkupFilter()
|
|
1087
1164
|
thinking_markup_suppressed = False
|
|
1165
|
+
repeated_completed_tool_dropped = False
|
|
1166
|
+
runaway_policy = policy_from_env(os.environ.get)
|
|
1167
|
+
text_runaway = RunawayOutputDetector(runaway_policy)
|
|
1168
|
+
thinking_runaway = RunawayOutputDetector(runaway_policy)
|
|
1169
|
+
runaway_verdict: RunawayVerdict | None = None
|
|
1088
1170
|
sse_trace = make_outgoing_sse_trace(provider, model, "ollama_stream", source_body)
|
|
1089
1171
|
sse_trace_outcome = "started"
|
|
1090
1172
|
sse_trace_error: str | None = None
|
|
@@ -1187,13 +1269,56 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1187
1269
|
emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
|
|
1188
1270
|
text_block_open = False
|
|
1189
1271
|
|
|
1272
|
+
def open_thinking_block() -> int:
|
|
1273
|
+
nonlocal next_content_index, thinking_block_open, thinking_block_text
|
|
1274
|
+
nonlocal thinking_index, thinking_started
|
|
1275
|
+
if thinking_block_open and thinking_index is not None:
|
|
1276
|
+
return thinking_index
|
|
1277
|
+
thinking_index = next_content_index
|
|
1278
|
+
next_content_index += 1
|
|
1279
|
+
thinking_started = True
|
|
1280
|
+
thinking_block_open = True
|
|
1281
|
+
thinking_block_text = ""
|
|
1282
|
+
emit(
|
|
1283
|
+
"content_block_start",
|
|
1284
|
+
{
|
|
1285
|
+
"type": "content_block_start",
|
|
1286
|
+
"index": thinking_index,
|
|
1287
|
+
"content_block": {"type": "thinking", "thinking": ""},
|
|
1288
|
+
},
|
|
1289
|
+
)
|
|
1290
|
+
return thinking_index
|
|
1291
|
+
|
|
1292
|
+
def close_thinking_block() -> None:
|
|
1293
|
+
nonlocal thinking_block_open
|
|
1294
|
+
if not thinking_block_open or thinking_index is None:
|
|
1295
|
+
return
|
|
1296
|
+
digest = hashlib.sha256(
|
|
1297
|
+
thinking_block_text.encode("utf-8", errors="replace")
|
|
1298
|
+
).hexdigest()[:24]
|
|
1299
|
+
emit(
|
|
1300
|
+
"content_block_delta",
|
|
1301
|
+
{
|
|
1302
|
+
"type": "content_block_delta",
|
|
1303
|
+
"index": thinking_index,
|
|
1304
|
+
"delta": {
|
|
1305
|
+
"type": "signature_delta",
|
|
1306
|
+
"signature": f"ciel-runtime-ollama-thinking-{digest}",
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
)
|
|
1310
|
+
emit("content_block_stop", {"type": "content_block_stop", "index": thinking_index})
|
|
1311
|
+
thinking_block_open = False
|
|
1312
|
+
|
|
1190
1313
|
def update_stream_activity(force: bool = False) -> None:
|
|
1191
1314
|
nonlocal last_activity_update
|
|
1192
1315
|
now = time.time()
|
|
1193
1316
|
if not force and now - last_activity_update < 0.5:
|
|
1194
1317
|
return
|
|
1195
1318
|
last_activity_update = now
|
|
1196
|
-
estimated_output = output_tokens or max(
|
|
1319
|
+
estimated_output = output_tokens or max(
|
|
1320
|
+
0, (len(thinking_so_far) + len(text_so_far)) // 4
|
|
1321
|
+
)
|
|
1197
1322
|
write_router_activity(
|
|
1198
1323
|
"request",
|
|
1199
1324
|
provider,
|
|
@@ -1205,9 +1330,12 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1205
1330
|
)
|
|
1206
1331
|
|
|
1207
1332
|
def handle_text_chunk(text_chunk: str) -> None:
|
|
1208
|
-
nonlocal text_buffer, text_so_far, text_suppressed_for_plan
|
|
1333
|
+
nonlocal text_buffer, text_so_far, text_suppressed_for_plan, runaway_verdict
|
|
1209
1334
|
if not text_chunk:
|
|
1210
1335
|
return
|
|
1336
|
+
if runaway_verdict is None:
|
|
1337
|
+
runaway_verdict = text_runaway.feed(text_chunk)
|
|
1338
|
+
close_thinking_block()
|
|
1211
1339
|
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, []):
|
|
1212
1340
|
text_so_far += text_chunk
|
|
1213
1341
|
text_suppressed_for_plan = True
|
|
@@ -1257,6 +1385,26 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1257
1385
|
emit("content_block_delta", event)
|
|
1258
1386
|
update_stream_activity()
|
|
1259
1387
|
|
|
1388
|
+
def handle_thinking_chunk(thinking_chunk: str) -> None:
|
|
1389
|
+
nonlocal thinking_block_text, thinking_so_far, runaway_verdict
|
|
1390
|
+
if not thinking_chunk:
|
|
1391
|
+
return
|
|
1392
|
+
if runaway_verdict is None:
|
|
1393
|
+
runaway_verdict = thinking_runaway.feed(thinking_chunk)
|
|
1394
|
+
close_text_block()
|
|
1395
|
+
active_thinking_index = open_thinking_block()
|
|
1396
|
+
thinking_block_text += thinking_chunk
|
|
1397
|
+
thinking_so_far += thinking_chunk
|
|
1398
|
+
emit(
|
|
1399
|
+
"content_block_delta",
|
|
1400
|
+
{
|
|
1401
|
+
"type": "content_block_delta",
|
|
1402
|
+
"index": active_thinking_index,
|
|
1403
|
+
"delta": {"type": "thinking_delta", "thinking": thinking_chunk},
|
|
1404
|
+
},
|
|
1405
|
+
)
|
|
1406
|
+
update_stream_activity()
|
|
1407
|
+
|
|
1260
1408
|
try:
|
|
1261
1409
|
for line in iter_upstream_lines_until_client_disconnect(handler, resp, idle_timeout):
|
|
1262
1410
|
chunks_seen += 1
|
|
@@ -1274,6 +1422,8 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1274
1422
|
output_tokens = max(output_tokens, int(chunk.get("eval_count") or 0))
|
|
1275
1423
|
if not started:
|
|
1276
1424
|
ensure_message_started()
|
|
1425
|
+
# Native Ollama thinking is a distinct field, not visible answer text.
|
|
1426
|
+
handle_thinking_chunk(str(message.get("thinking") or ""))
|
|
1277
1427
|
# Handle text content
|
|
1278
1428
|
raw_text_chunk = str(message.get("content") or "")
|
|
1279
1429
|
text_chunk = thinking_markup_filter.feed(raw_text_chunk)
|
|
@@ -1290,16 +1440,20 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1290
1440
|
matched_name = resolve_emitted_tool_name(raw_name, source_body)
|
|
1291
1441
|
raw_args = fn.get("arguments")
|
|
1292
1442
|
normalized_args = normalize_tool_arguments(matched_name, raw_args)
|
|
1443
|
+
emitted_name = matched_name
|
|
1293
1444
|
fixed_input = _validate_and_fix_tool_input(matched_name, normalized_args)
|
|
1294
1445
|
if source_body is not None:
|
|
1295
1446
|
matched_name, fixed_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
|
|
1296
1447
|
if matched_name is None:
|
|
1297
1448
|
continue
|
|
1298
1449
|
fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
|
|
1299
|
-
if
|
|
1450
|
+
supplied_input = normalized_args if matched_name == emitted_name else None
|
|
1451
|
+
if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body, supplied_input):
|
|
1300
1452
|
continue
|
|
1301
|
-
if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
|
|
1453
|
+
if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name, source_body):
|
|
1454
|
+
repeated_completed_tool_dropped = True
|
|
1302
1455
|
continue
|
|
1456
|
+
close_thinking_block()
|
|
1303
1457
|
close_text_block()
|
|
1304
1458
|
tool_calls.append({"function": {"name": matched_name, "arguments": fixed_input}})
|
|
1305
1459
|
tool_id = f"toolu_ollama_{int(time.time() * 1000)}_{len(tool_calls) - 1}"
|
|
@@ -1322,14 +1476,37 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1322
1476
|
emit_tool_block(tool_index, tool_id, matched_name, fixed_input)
|
|
1323
1477
|
update_stream_activity()
|
|
1324
1478
|
update_stream_activity()
|
|
1479
|
+
if runaway_verdict is not None:
|
|
1480
|
+
# Stop reading upstream. The finally block closes the response,
|
|
1481
|
+
# which ends generation instead of paying for the whole loop.
|
|
1482
|
+
router_log(
|
|
1483
|
+
"WARN",
|
|
1484
|
+
f"ollama_stream_runaway_repetition provider={provider} model={model} "
|
|
1485
|
+
f"chunks={chunks_seen} {runaway_verdict.log_fields()}",
|
|
1486
|
+
)
|
|
1487
|
+
write_router_activity(
|
|
1488
|
+
"error", provider, model, error="runaway_repetition", stream=True
|
|
1489
|
+
)
|
|
1490
|
+
break
|
|
1491
|
+
runaway_stopped = runaway_verdict is not None
|
|
1325
1492
|
trailing_text = thinking_markup_filter.finish()
|
|
1326
|
-
if trailing_text:
|
|
1493
|
+
if trailing_text and not runaway_stopped:
|
|
1327
1494
|
handle_text_chunk(trailing_text)
|
|
1328
1495
|
if thinking_markup_suppressed:
|
|
1329
1496
|
router_log("WARN", f"suppressed visible Ollama thinking markup from stream model={model}")
|
|
1330
1497
|
update_stream_activity(force=True)
|
|
1498
|
+
close_thinking_block()
|
|
1499
|
+
if not runaway_stopped and repeated_completed_tool_dropped and not text_so_far.strip() and not tool_calls:
|
|
1500
|
+
handle_text_chunk(
|
|
1501
|
+
"[ciel-runtime] Stopped an identical completed tool call from repeating. "
|
|
1502
|
+
"The previous result is already in context; choose a different action or finish the turn."
|
|
1503
|
+
)
|
|
1504
|
+
reasoning_only = thinking_started and not text_so_far.strip() and not tool_calls and not runaway_stopped
|
|
1505
|
+
# A looping turn must not be continued for the model. Every synthesis
|
|
1506
|
+
# below exists to keep work moving, which is the opposite of what a
|
|
1507
|
+
# runaway needs, so they are all skipped once the guard has fired.
|
|
1331
1508
|
# Flush any remaining buffered text when word-chunking is active
|
|
1332
|
-
if source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
|
|
1509
|
+
if not runaway_stopped and not reasoning_only and source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
|
|
1333
1510
|
ensure_message_started()
|
|
1334
1511
|
close_text_block()
|
|
1335
1512
|
router_log("WARN", "auto-synthesized EnterPlanMode from short/empty upstream stream")
|
|
@@ -1339,7 +1516,7 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1339
1516
|
next_content_index += 1
|
|
1340
1517
|
tool_indices.append(tool_index)
|
|
1341
1518
|
emit_tool_block(tool_index, tool_id, "EnterPlanMode", {})
|
|
1342
|
-
elif source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1519
|
+
elif not runaway_stopped and not reasoning_only and source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1343
1520
|
ensure_message_started()
|
|
1344
1521
|
close_text_block()
|
|
1345
1522
|
router_log("WARN", "auto-synthesized TaskList from empty upstream end_turn stream")
|
|
@@ -1357,7 +1534,7 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1357
1534
|
"delta": {"type": "text_delta", "text": text_so_far},
|
|
1358
1535
|
}
|
|
1359
1536
|
emit("content_block_delta", event)
|
|
1360
|
-
if source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1537
|
+
if not runaway_stopped and not reasoning_only and source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1361
1538
|
ensure_message_started()
|
|
1362
1539
|
close_text_block()
|
|
1363
1540
|
router_log("WARN", "auto-synthesized TaskList to keep work moving after tool result stream")
|
|
@@ -1367,7 +1544,7 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1367
1544
|
next_content_index += 1
|
|
1368
1545
|
tool_indices.append(tool_index)
|
|
1369
1546
|
emit_tool_block(tool_index, tool_id, "TaskList", {})
|
|
1370
|
-
if source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1547
|
+
if not runaway_stopped and not reasoning_only and source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1371
1548
|
ensure_message_started()
|
|
1372
1549
|
close_text_block()
|
|
1373
1550
|
router_log("WARN", "auto-synthesized TaskList after clarification question stream")
|
|
@@ -1388,7 +1565,41 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1388
1565
|
stopped_tool_indices.add(tool_index)
|
|
1389
1566
|
if not started:
|
|
1390
1567
|
ensure_message_started()
|
|
1391
|
-
if
|
|
1568
|
+
if runaway_stopped and runaway_verdict is not None:
|
|
1569
|
+
notice = runaway_verdict.notice(STOPPED)
|
|
1570
|
+
text_so_far += notice
|
|
1571
|
+
notice_index = next_content_index
|
|
1572
|
+
next_content_index += 1
|
|
1573
|
+
emit_text_block(notice_index, notice)
|
|
1574
|
+
# Cutting the loop should not cost the user the turn. Hand the agent
|
|
1575
|
+
# a tool call so the CLI comes back for a fresh one -- unless a
|
|
1576
|
+
# recent turn already carries this notice, which would mean the
|
|
1577
|
+
# recovery itself is looping.
|
|
1578
|
+
if (
|
|
1579
|
+
not tool_calls
|
|
1580
|
+
and runaway_policy.recover
|
|
1581
|
+
and recent_runaway_notices(source_body) == 0
|
|
1582
|
+
):
|
|
1583
|
+
router_log("WARN", f"ollama_stream_runaway_recovery provider={provider} model={model}")
|
|
1584
|
+
tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
|
|
1585
|
+
tool_id = f"toolu_ollama_runaway_{int(time.time() * 1000)}"
|
|
1586
|
+
tool_index = next_content_index
|
|
1587
|
+
next_content_index += 1
|
|
1588
|
+
tool_indices.append(tool_index)
|
|
1589
|
+
emit_tool_block(tool_index, tool_id, "TaskList", {})
|
|
1590
|
+
elif reasoning_only:
|
|
1591
|
+
router_log(
|
|
1592
|
+
"WARN",
|
|
1593
|
+
f"ollama_reasoning_only_stream provider={provider} model={model} "
|
|
1594
|
+
f"chunks={chunks_seen} thinking_chars={len(thinking_so_far)} "
|
|
1595
|
+
f"done_reason={str(chunk.get('done_reason') or '-')}",
|
|
1596
|
+
)
|
|
1597
|
+
notice = reasoning_only_notice(str(chunk.get("done_reason") or ""))
|
|
1598
|
+
text_so_far = notice
|
|
1599
|
+
notice_index = next_content_index
|
|
1600
|
+
next_content_index += 1
|
|
1601
|
+
emit_text_block(notice_index, notice)
|
|
1602
|
+
elif not text_started and not tool_indices:
|
|
1392
1603
|
router_log("WARN", f"ollama_empty_stream provider={provider} model={model} chunks={chunks_seen}")
|
|
1393
1604
|
write_router_activity("error", provider, model, error="empty_stream", stream=True)
|
|
1394
1605
|
empty_index = next_content_index
|
|
@@ -1401,6 +1612,10 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1401
1612
|
stop_reason = "tool_use" if tool_calls else "end_turn"
|
|
1402
1613
|
if chunk.get("done_reason") == "length":
|
|
1403
1614
|
stop_reason = "max_tokens"
|
|
1615
|
+
if runaway_stopped and not tool_calls:
|
|
1616
|
+
# No continuation was synthesized, so the turn really does end here
|
|
1617
|
+
# with output the router truncated.
|
|
1618
|
+
stop_reason = "max_tokens"
|
|
1404
1619
|
# Send message_delta with final stop_reason
|
|
1405
1620
|
event = {
|
|
1406
1621
|
"type": "message_delta",
|
|
@@ -1411,13 +1626,14 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1411
1626
|
# Send message_stop
|
|
1412
1627
|
emit("message_stop", {"type": "message_stop"})
|
|
1413
1628
|
sse_trace_outcome = "success"
|
|
1414
|
-
if text_started or tool_indices:
|
|
1629
|
+
if thinking_started or text_started or tool_indices:
|
|
1415
1630
|
write_router_activity(
|
|
1416
1631
|
"success",
|
|
1417
1632
|
provider,
|
|
1418
1633
|
model,
|
|
1419
1634
|
tokens=input_tokens,
|
|
1420
|
-
output_tokens=output_tokens
|
|
1635
|
+
output_tokens=output_tokens
|
|
1636
|
+
or max(1, (len(thinking_so_far) + len(text_so_far)) // 4),
|
|
1421
1637
|
chunks=chunks_seen,
|
|
1422
1638
|
stream=True,
|
|
1423
1639
|
)
|
|
@@ -1429,7 +1645,8 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1429
1645
|
router_log(
|
|
1430
1646
|
"WARN",
|
|
1431
1647
|
f"ollama_stream_client_disconnected provider={provider} model={model} "
|
|
1432
|
-
f"chunks={chunks_seen}
|
|
1648
|
+
f"chunks={chunks_seen} thinking_len={len(thinking_so_far)} "
|
|
1649
|
+
f"text_len={len(text_so_far)} error={exc}",
|
|
1433
1650
|
)
|
|
1434
1651
|
write_router_activity(
|
|
1435
1652
|
"cancel",
|
|
@@ -1437,7 +1654,8 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1437
1654
|
model,
|
|
1438
1655
|
error=type(exc).__name__,
|
|
1439
1656
|
tokens=input_tokens,
|
|
1440
|
-
output_tokens=output_tokens
|
|
1657
|
+
output_tokens=output_tokens
|
|
1658
|
+
or max(0, (len(thinking_so_far) + len(text_so_far)) // 4),
|
|
1441
1659
|
chunks=chunks_seen,
|
|
1442
1660
|
stream=True,
|
|
1443
1661
|
)
|
|
@@ -1449,6 +1667,8 @@ def ollama_stream_to_anthropic_sse(
|
|
|
1449
1667
|
write_router_activity("error", provider, model, error=type(exc).__name__, stream=True)
|
|
1450
1668
|
try:
|
|
1451
1669
|
ensure_message_started()
|
|
1670
|
+
if thinking_block_open:
|
|
1671
|
+
close_thinking_block()
|
|
1452
1672
|
if text_block_open:
|
|
1453
1673
|
close_text_block()
|
|
1454
1674
|
if not text_started and not tool_indices:
|
|
@@ -1594,9 +1814,17 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1594
1814
|
reasoning_so_far = ""
|
|
1595
1815
|
tool_fragments: dict[int, dict[str, Any]] = {}
|
|
1596
1816
|
output_tokens = 0
|
|
1817
|
+
reported_input_tokens = max(0, int(input_tokens or 0))
|
|
1818
|
+
cache_read_tokens = 0
|
|
1819
|
+
cache_creation_tokens = 0
|
|
1597
1820
|
finish_reason = "stop"
|
|
1598
1821
|
chunks_seen = 0
|
|
1599
1822
|
last_activity_update = 0.0
|
|
1823
|
+
runaway_policy = policy_from_env(os.environ.get)
|
|
1824
|
+
text_runaway = RunawayOutputDetector(runaway_policy)
|
|
1825
|
+
reasoning_runaway = RunawayOutputDetector(runaway_policy)
|
|
1826
|
+
runaway_verdict: RunawayVerdict | None = None
|
|
1827
|
+
runaway_stopped = False
|
|
1600
1828
|
|
|
1601
1829
|
def emit(event_name: str, payload: dict[str, Any]) -> None:
|
|
1602
1830
|
handler.wfile.write(f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode())
|
|
@@ -1708,19 +1936,70 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1708
1936
|
usage = event.get("usage")
|
|
1709
1937
|
if isinstance(usage, dict):
|
|
1710
1938
|
output_tokens = max(output_tokens, positive_int(usage.get("completion_tokens")) or 0)
|
|
1939
|
+
prompt_tokens = positive_int(usage.get("prompt_tokens")) or 0
|
|
1940
|
+
details = usage.get("prompt_tokens_details")
|
|
1941
|
+
details = details if isinstance(details, dict) else {}
|
|
1942
|
+
cache_read_tokens = max(
|
|
1943
|
+
cache_read_tokens,
|
|
1944
|
+
positive_int(usage.get("prompt_cache_hit_tokens"))
|
|
1945
|
+
or positive_int(usage.get("cache_read_input_tokens"))
|
|
1946
|
+
or positive_int(details.get("cached_tokens"))
|
|
1947
|
+
or 0,
|
|
1948
|
+
)
|
|
1949
|
+
cache_creation_tokens = max(
|
|
1950
|
+
cache_creation_tokens,
|
|
1951
|
+
positive_int(usage.get("cache_creation_input_tokens"))
|
|
1952
|
+
or positive_int(details.get("cache_write_tokens"))
|
|
1953
|
+
or 0,
|
|
1954
|
+
)
|
|
1955
|
+
if prompt_tokens:
|
|
1956
|
+
reported_input_tokens = max(
|
|
1957
|
+
0,
|
|
1958
|
+
prompt_tokens - cache_read_tokens - cache_creation_tokens,
|
|
1959
|
+
)
|
|
1711
1960
|
choices = event.get("choices")
|
|
1712
1961
|
if not isinstance(choices, list) or not choices:
|
|
1713
1962
|
continue
|
|
1714
1963
|
choice = choices[0] if isinstance(choices[0], dict) else {}
|
|
1964
|
+
choice_usage = choice.get("usage")
|
|
1965
|
+
if isinstance(choice_usage, dict):
|
|
1966
|
+
output_tokens = max(
|
|
1967
|
+
output_tokens,
|
|
1968
|
+
positive_int(choice_usage.get("completion_tokens")) or 0,
|
|
1969
|
+
)
|
|
1970
|
+
prompt_tokens = positive_int(choice_usage.get("prompt_tokens")) or 0
|
|
1971
|
+
details = choice_usage.get("prompt_tokens_details")
|
|
1972
|
+
details = details if isinstance(details, dict) else {}
|
|
1973
|
+
cache_read_tokens = max(
|
|
1974
|
+
cache_read_tokens,
|
|
1975
|
+
positive_int(choice_usage.get("prompt_cache_hit_tokens"))
|
|
1976
|
+
or positive_int(choice_usage.get("cache_read_input_tokens"))
|
|
1977
|
+
or positive_int(details.get("cached_tokens"))
|
|
1978
|
+
or 0,
|
|
1979
|
+
)
|
|
1980
|
+
if prompt_tokens:
|
|
1981
|
+
reported_input_tokens = max(0, prompt_tokens - cache_read_tokens)
|
|
1715
1982
|
if choice.get("finish_reason"):
|
|
1716
1983
|
finish_reason = str(choice.get("finish_reason"))
|
|
1717
1984
|
delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
|
|
1718
1985
|
reasoning_chunk = delta.get("reasoning_content") or ""
|
|
1719
1986
|
if reasoning_chunk:
|
|
1720
1987
|
reasoning_so_far += str(reasoning_chunk)
|
|
1988
|
+
runaway_verdict = runaway_verdict or reasoning_runaway.feed(str(reasoning_chunk))
|
|
1721
1989
|
emit_reasoning_delta(str(reasoning_chunk))
|
|
1722
1990
|
update_stream_activity()
|
|
1723
1991
|
text_chunk = delta.get("content") or ""
|
|
1992
|
+
if text_chunk:
|
|
1993
|
+
runaway_verdict = runaway_verdict or text_runaway.feed(str(text_chunk))
|
|
1994
|
+
if runaway_verdict is not None:
|
|
1995
|
+
# Stop reading upstream; the finally block closes the response.
|
|
1996
|
+
runaway_stopped = True
|
|
1997
|
+
router_log(
|
|
1998
|
+
"WARN",
|
|
1999
|
+
f"openai_stream_runaway_repetition provider={provider} model={model} "
|
|
2000
|
+
f"chunks={chunks_seen} {runaway_verdict.log_fields()}",
|
|
2001
|
+
)
|
|
2002
|
+
break
|
|
1724
2003
|
if text_chunk:
|
|
1725
2004
|
close_reasoning_block()
|
|
1726
2005
|
if pseudo_mode or PSEUDO_TOOL_START in text_chunk:
|
|
@@ -1800,15 +2079,17 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1800
2079
|
continue
|
|
1801
2080
|
matched_name = resolve_emitted_tool_name(raw_name, source_body)
|
|
1802
2081
|
normalized_args = normalize_tool_arguments(matched_name, fragment.get("arguments") or {})
|
|
2082
|
+
emitted_name = matched_name
|
|
1803
2083
|
fixed_input = _validate_and_fix_tool_input(matched_name, normalized_args)
|
|
1804
2084
|
if source_body is not None:
|
|
1805
2085
|
matched_name, fixed_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
|
|
1806
2086
|
if matched_name is None:
|
|
1807
2087
|
continue
|
|
1808
2088
|
fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
|
|
1809
|
-
if
|
|
2089
|
+
supplied_input = normalized_args if matched_name == emitted_name else None
|
|
2090
|
+
if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body, supplied_input):
|
|
1810
2091
|
continue
|
|
1811
|
-
if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
|
|
2092
|
+
if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name, source_body):
|
|
1812
2093
|
continue
|
|
1813
2094
|
tool_calls.append({"function": {"name": matched_name, "arguments": fixed_input}})
|
|
1814
2095
|
tool_index = next_content_index
|
|
@@ -1844,7 +2125,7 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1844
2125
|
)
|
|
1845
2126
|
emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
|
|
1846
2127
|
|
|
1847
|
-
if source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
|
|
2128
|
+
if not runaway_stopped and source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
|
|
1848
2129
|
router_log("WARN", "auto-synthesized EnterPlanMode from short/empty upstream OpenAI stream")
|
|
1849
2130
|
tool_index = next_content_index
|
|
1850
2131
|
next_content_index += 1
|
|
@@ -1859,7 +2140,7 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1859
2140
|
)
|
|
1860
2141
|
emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
|
|
1861
2142
|
emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
|
|
1862
|
-
elif source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
|
|
2143
|
+
elif not runaway_stopped and source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1863
2144
|
router_log("WARN", "auto-synthesized TaskList from empty upstream end_turn OpenAI stream")
|
|
1864
2145
|
tool_index = next_content_index
|
|
1865
2146
|
next_content_index += 1
|
|
@@ -1877,7 +2158,7 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1877
2158
|
elif text_suppressed_for_plan and not text_started and text_so_far:
|
|
1878
2159
|
emit_text_delta(text_so_far)
|
|
1879
2160
|
|
|
1880
|
-
if source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
|
|
2161
|
+
if not runaway_stopped and source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1881
2162
|
router_log("WARN", "auto-synthesized TaskList to keep work moving after OpenAI stream")
|
|
1882
2163
|
tool_index = next_content_index
|
|
1883
2164
|
next_content_index += 1
|
|
@@ -1893,7 +2174,7 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1893
2174
|
emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
|
|
1894
2175
|
emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
|
|
1895
2176
|
|
|
1896
|
-
if source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
|
|
2177
|
+
if not runaway_stopped and source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
|
|
1897
2178
|
router_log("WARN", "auto-synthesized TaskList after clarification question OpenAI stream")
|
|
1898
2179
|
tool_index = next_content_index
|
|
1899
2180
|
next_content_index += 1
|
|
@@ -1909,10 +2190,40 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1909
2190
|
emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
|
|
1910
2191
|
emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
|
|
1911
2192
|
|
|
2193
|
+
if runaway_stopped and runaway_verdict is not None:
|
|
2194
|
+
notice = ("\n\n" if text_started else "") + runaway_verdict.notice(STOPPED)
|
|
2195
|
+
text_so_far += notice
|
|
2196
|
+
emit_text_delta(notice)
|
|
1912
2197
|
if text_started and text_index is not None:
|
|
1913
2198
|
emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
|
|
1914
2199
|
text_stopped = True
|
|
1915
|
-
if
|
|
2200
|
+
if (
|
|
2201
|
+
runaway_stopped
|
|
2202
|
+
and not tool_calls
|
|
2203
|
+
and runaway_policy.recover
|
|
2204
|
+
and recent_runaway_notices(source_body) == 0
|
|
2205
|
+
):
|
|
2206
|
+
# Give the agent another turn instead of ending on a cut-off answer.
|
|
2207
|
+
router_log("WARN", f"openai_stream_runaway_recovery provider={provider} model={model}")
|
|
2208
|
+
tool_index = next_content_index
|
|
2209
|
+
next_content_index += 1
|
|
2210
|
+
tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
|
|
2211
|
+
emit(
|
|
2212
|
+
"content_block_start",
|
|
2213
|
+
{
|
|
2214
|
+
"type": "content_block_start",
|
|
2215
|
+
"index": tool_index,
|
|
2216
|
+
"content_block": {
|
|
2217
|
+
"type": "tool_use",
|
|
2218
|
+
"id": f"toolu_openai_runaway_{int(time.time() * 1000)}",
|
|
2219
|
+
"name": "TaskList",
|
|
2220
|
+
"input": {},
|
|
2221
|
+
},
|
|
2222
|
+
},
|
|
2223
|
+
)
|
|
2224
|
+
emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
|
|
2225
|
+
emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
|
|
2226
|
+
if not runaway_stopped and not text_started and not tool_calls:
|
|
1916
2227
|
text_so_far = empty_end_turn_notice_for_body(source_body) if source_body is not None else ""
|
|
1917
2228
|
if source_body is not None:
|
|
1918
2229
|
router_log(
|
|
@@ -1925,7 +2236,33 @@ def forward_openai_chat_to_anthropic_sse(
|
|
|
1925
2236
|
emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
|
|
1926
2237
|
text_stopped = True
|
|
1927
2238
|
stop_reason = "tool_use" if tool_calls else ("max_tokens" if finish_reason == "length" else "end_turn")
|
|
1928
|
-
|
|
2239
|
+
if runaway_stopped and not tool_calls:
|
|
2240
|
+
# No continuation was synthesized, so the turn ends on truncated output.
|
|
2241
|
+
stop_reason = "max_tokens"
|
|
2242
|
+
final_output_tokens = output_tokens or max(1, len(text_so_far) // 4)
|
|
2243
|
+
final_usage = {
|
|
2244
|
+
"input_tokens": reported_input_tokens,
|
|
2245
|
+
"output_tokens": final_output_tokens,
|
|
2246
|
+
}
|
|
2247
|
+
if cache_read_tokens:
|
|
2248
|
+
final_usage["cache_read_input_tokens"] = cache_read_tokens
|
|
2249
|
+
if cache_creation_tokens:
|
|
2250
|
+
final_usage["cache_creation_input_tokens"] = cache_creation_tokens
|
|
2251
|
+
write_anthropic_open_stream_stop(
|
|
2252
|
+
handler,
|
|
2253
|
+
{"stop_reason": stop_reason, "usage": final_usage},
|
|
2254
|
+
)
|
|
2255
|
+
write_router_activity(
|
|
2256
|
+
"success",
|
|
2257
|
+
provider,
|
|
2258
|
+
model,
|
|
2259
|
+
input_tokens=reported_input_tokens,
|
|
2260
|
+
output_tokens=final_output_tokens,
|
|
2261
|
+
cache_read_tokens=cache_read_tokens,
|
|
2262
|
+
cache_creation_tokens=cache_creation_tokens,
|
|
2263
|
+
chunks=chunks_seen,
|
|
2264
|
+
stream=True,
|
|
2265
|
+
)
|
|
1929
2266
|
return True
|
|
1930
2267
|
except Exception as exc:
|
|
1931
2268
|
router_log("ERROR", f"openai_stream_error provider={provider} model={model} error={type(exc).__name__}: {exc}")
|