@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
|
@@ -47,6 +47,73 @@ class OpenAiHistoryServices:
|
|
|
47
47
|
log: Callable[[str, str], None]
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def openai_multimodal_content(content: Any, text: ChatProjectionText) -> Any:
|
|
51
|
+
if not isinstance(content, list):
|
|
52
|
+
return text.compact_text(text.content_to_text(content))
|
|
53
|
+
projected: list[dict[str, Any]] = []
|
|
54
|
+
fallback: list[Any] = []
|
|
55
|
+
for block in content:
|
|
56
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
57
|
+
value = text.compact_text(str(block.get("text") or ""))
|
|
58
|
+
if value:
|
|
59
|
+
projected.append({"type": "text", "text": value})
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(block, dict) and block.get("type") == "image":
|
|
62
|
+
source = block.get("source") if isinstance(block.get("source"), dict) else {}
|
|
63
|
+
if source.get("type") == "base64" and source.get("data"):
|
|
64
|
+
media_type = str(source.get("media_type") or "image/png")
|
|
65
|
+
projected.append(
|
|
66
|
+
{
|
|
67
|
+
"type": "image_url",
|
|
68
|
+
"image_url": {"url": f"data:{media_type};base64,{source['data']}"},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
72
|
+
if source.get("type") == "url" and source.get("url"):
|
|
73
|
+
projected.append(
|
|
74
|
+
{"type": "image_url", "image_url": {"url": str(source["url"])}}
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
fallback.append(block)
|
|
78
|
+
fallback_text = text.compact_text(text.content_to_text(fallback))
|
|
79
|
+
if fallback_text:
|
|
80
|
+
projected.append({"type": "text", "text": fallback_text})
|
|
81
|
+
return projected if any(item.get("type") == "image_url" for item in projected) else text.compact_text(text.content_to_text(content))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def closing_system_message_indexes(
|
|
85
|
+
body: dict[str, Any], text: ChatProjectionText
|
|
86
|
+
) -> set[int]:
|
|
87
|
+
"""Indexes of the system messages a conversation ends on.
|
|
88
|
+
|
|
89
|
+
Anthropic lets a client hand over input that arrived while a turn was still
|
|
90
|
+
running as a system message placed after the last turn; Claude Code delivers
|
|
91
|
+
what the user typed mid-task that way. A chat wire has no such envelope, and
|
|
92
|
+
the difference is not cosmetic: replaying a captured Claude Code request
|
|
93
|
+
against ollama-cloud/deepseek-v4-flash, the same text was acted on 0/10
|
|
94
|
+
times as the closing system message and 9/10 as a user message, while moving
|
|
95
|
+
it earlier or removing the surrounding router text changed nothing.
|
|
96
|
+
|
|
97
|
+
Only the closing run counts. A system message with further conversation
|
|
98
|
+
after it is background the model has already had a turn to act on.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
messages = body.get("messages", []) or []
|
|
102
|
+
kept = [
|
|
103
|
+
index
|
|
104
|
+
for index, message in enumerate(messages)
|
|
105
|
+
if isinstance(message, dict)
|
|
106
|
+
and not text.attachment_only(message)
|
|
107
|
+
and not text.skip_message(message)
|
|
108
|
+
]
|
|
109
|
+
closing: set[int] = set()
|
|
110
|
+
for index in reversed(kept):
|
|
111
|
+
if messages[index].get("role") != "system":
|
|
112
|
+
break
|
|
113
|
+
closing.add(index)
|
|
114
|
+
return closing
|
|
115
|
+
|
|
116
|
+
|
|
50
117
|
def anthropic_messages_to_ollama(body: dict[str, Any], *, services: ChatProjectionServices) -> list[dict[str, Any]]:
|
|
51
118
|
text = services.text
|
|
52
119
|
tools = services.tools
|
|
@@ -57,10 +124,13 @@ def anthropic_messages_to_ollama(body: dict[str, Any], *, services: ChatProjecti
|
|
|
57
124
|
in_plan_mode = tools.plan_mode_active(body)
|
|
58
125
|
tool_names_by_id: dict[str, str] = {}
|
|
59
126
|
tool_inputs_by_id: dict[str, Any] = {}
|
|
60
|
-
|
|
127
|
+
closing_system = closing_system_message_indexes(body, text)
|
|
128
|
+
for index, message in enumerate(body.get("messages", []) or []):
|
|
61
129
|
if not isinstance(message, dict) or text.attachment_only(message) or text.skip_message(message):
|
|
62
130
|
continue
|
|
63
131
|
role = message.get("role", "user")
|
|
132
|
+
if role == "system" and index in closing_system:
|
|
133
|
+
role = "user"
|
|
64
134
|
content = message.get("content", "")
|
|
65
135
|
if role == "user" and isinstance(content, list):
|
|
66
136
|
text_blocks: list[Any] = []
|
|
@@ -111,7 +181,13 @@ def anthropic_messages_to_ollama(body: dict[str, Any], *, services: ChatProjecti
|
|
|
111
181
|
out: dict[str, Any] = {"role": role, "content": text.compact_text(message_text)}
|
|
112
182
|
if role == "assistant" and isinstance(content, list):
|
|
113
183
|
calls = []
|
|
184
|
+
thinking_parts: list[str] = []
|
|
114
185
|
for block in content:
|
|
186
|
+
if isinstance(block, dict) and block.get("type") == "thinking":
|
|
187
|
+
thinking = str(block.get("thinking") or "")
|
|
188
|
+
if thinking:
|
|
189
|
+
thinking_parts.append(thinking)
|
|
190
|
+
continue
|
|
115
191
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
116
192
|
name = str(block.get("name") or "tool")
|
|
117
193
|
tool_id = str(block.get("id") or "")
|
|
@@ -121,6 +197,8 @@ def anthropic_messages_to_ollama(body: dict[str, Any], *, services: ChatProjecti
|
|
|
121
197
|
calls.append({"function": {"name": name, "arguments": block.get("input") or {}}})
|
|
122
198
|
if calls:
|
|
123
199
|
out["tool_calls"] = calls
|
|
200
|
+
if thinking_parts:
|
|
201
|
+
out["thinking"] = "\n".join(thinking_parts)
|
|
124
202
|
messages.append(out)
|
|
125
203
|
return messages
|
|
126
204
|
|
|
@@ -169,11 +247,13 @@ def anthropic_messages_to_openai(
|
|
|
169
247
|
})
|
|
170
248
|
else:
|
|
171
249
|
text_blocks.append(block)
|
|
172
|
-
out: dict[str, Any] = {"role": "assistant", "content":
|
|
250
|
+
out: dict[str, Any] = {"role": "assistant", "content": openai_multimodal_content(text_blocks, text)}
|
|
173
251
|
if reasoning_seen or reasoning_passback:
|
|
174
252
|
out["reasoning_content"] = "\n".join(reasoning_parts)
|
|
175
253
|
if tool_calls:
|
|
176
254
|
out["tool_calls"] = tool_calls
|
|
255
|
+
if message.get("partial") is True:
|
|
256
|
+
out["partial"] = True
|
|
177
257
|
messages.append(out)
|
|
178
258
|
continue
|
|
179
259
|
if role == "user" and isinstance(content, list):
|
|
@@ -207,9 +287,9 @@ def anthropic_messages_to_openai(
|
|
|
207
287
|
messages.append({"role": "tool", "tool_call_id": tool_id, "id": tool_id, "content": tool_text})
|
|
208
288
|
else:
|
|
209
289
|
text_blocks.append(block)
|
|
210
|
-
|
|
211
|
-
if
|
|
212
|
-
messages.append({"role": "user", "content":
|
|
290
|
+
user_content = openai_multimodal_content(text_blocks, text)
|
|
291
|
+
if user_content:
|
|
292
|
+
messages.append({"role": "user", "content": user_content})
|
|
213
293
|
continue
|
|
214
294
|
out = {"role": role, "content": text.compact_text(text.content_to_text(content))}
|
|
215
295
|
if role == "assistant" and reasoning_passback:
|
|
@@ -782,6 +782,42 @@ class ConversationTurnPolicy:
|
|
|
782
782
|
self.latest_user_looks_like_work_request(body) and len(normalized) <= 1200
|
|
783
783
|
)
|
|
784
784
|
|
|
785
|
+
def should_retry_preamble_only_turn(
|
|
786
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
787
|
+
) -> bool:
|
|
788
|
+
"""Detect a turn that only announces work, for clients without TaskList.
|
|
789
|
+
|
|
790
|
+
The Anthropic path recovers this by synthesizing a TaskList call, which
|
|
791
|
+
needs a tool the Codex client does not expose. Here the caller replays the
|
|
792
|
+
request with an explicit continue turn instead, so this only has to decide
|
|
793
|
+
whether the reply is an announcement rather than an answer.
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
if tool_calls:
|
|
797
|
+
return False
|
|
798
|
+
text = (response_text or "").strip()
|
|
799
|
+
if not text:
|
|
800
|
+
# No text and no tool call is an empty turn, handled elsewhere.
|
|
801
|
+
return False
|
|
802
|
+
if self.latest_user_is_claude_code_suggestion_mode(body):
|
|
803
|
+
return False
|
|
804
|
+
if self.plan_mode_active(body):
|
|
805
|
+
return False
|
|
806
|
+
if self.latest_tool_result_indicates_completed_work(body):
|
|
807
|
+
return False
|
|
808
|
+
# Mid-work is the signal that matters: the client already ran tools this
|
|
809
|
+
# turn. Do not test the result tool names -- WORK_CONTINUATION_RESULT_TOOLS
|
|
810
|
+
# lists Claude Code tools, and a Codex client calls exec_command/apply_patch.
|
|
811
|
+
if self.latest_user_tool_result_names(body):
|
|
812
|
+
return self.non_actionable_short_response(text)
|
|
813
|
+
if not self.latest_user_looks_like_work_request(body):
|
|
814
|
+
return False
|
|
815
|
+
if self.non_actionable_short_response(text):
|
|
816
|
+
return True
|
|
817
|
+
# Structural only: a brief reply to an explicit work request is an
|
|
818
|
+
# announcement, never a finished result. Never inspect wording or domain.
|
|
819
|
+
return len(re.sub(r"\s+", " ", text)) <= 400
|
|
820
|
+
|
|
785
821
|
def should_recover_empty_end_turn_with_tasklist(
|
|
786
822
|
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
787
823
|
) -> bool:
|
|
@@ -977,6 +1013,13 @@ class ConversationTurnCompatibilityApi:
|
|
|
977
1013
|
def should_synthesize_tasklist_for_provider(self, provider: str) -> bool:
|
|
978
1014
|
return self.policy_factory().should_synthesize_tasklist_for_provider(provider)
|
|
979
1015
|
|
|
1016
|
+
def should_retry_preamble_only_turn(
|
|
1017
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
1018
|
+
) -> bool:
|
|
1019
|
+
return self.policy_factory().should_retry_preamble_only_turn(
|
|
1020
|
+
body, response_text, tool_calls
|
|
1021
|
+
)
|
|
1022
|
+
|
|
980
1023
|
def should_keep_work_alive_with_tasklist(
|
|
981
1024
|
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
982
1025
|
) -> bool:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -13,6 +14,7 @@ class DecodedOllamaChatResponse:
|
|
|
13
14
|
"""Provider-neutral values decoded from one Ollama `/api/chat` response."""
|
|
14
15
|
|
|
15
16
|
text: str
|
|
17
|
+
thinking: str
|
|
16
18
|
tool_calls: tuple[dict[str, Any], ...]
|
|
17
19
|
done_reason: str
|
|
18
20
|
input_tokens: int
|
|
@@ -26,6 +28,7 @@ def decode_ollama_chat_response(data: dict[str, Any]) -> DecodedOllamaChatRespon
|
|
|
26
28
|
calls = message.get("tool_calls") if isinstance(message, dict) else None
|
|
27
29
|
return DecodedOllamaChatResponse(
|
|
28
30
|
text=str(message.get("content") or ""),
|
|
31
|
+
thinking=str(message.get("thinking") or ""),
|
|
29
32
|
tool_calls=tuple(call for call in (calls or []) if isinstance(call, dict)),
|
|
30
33
|
done_reason=str(data.get("done_reason") or ""),
|
|
31
34
|
input_tokens=max(0, int(data.get("prompt_eval_count") or 0)),
|
|
@@ -33,6 +36,32 @@ def decode_ollama_chat_response(data: dict[str, Any]) -> DecodedOllamaChatRespon
|
|
|
33
36
|
)
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
def ollama_thinking_to_anthropic_block(thinking_content: Any) -> dict[str, Any] | None:
|
|
40
|
+
"""Project Ollama's native reasoning field without exposing it as visible text."""
|
|
41
|
+
|
|
42
|
+
thinking = str(thinking_content or "")
|
|
43
|
+
if not thinking:
|
|
44
|
+
return None
|
|
45
|
+
digest = hashlib.sha256(thinking.encode("utf-8", errors="replace")).hexdigest()[:24]
|
|
46
|
+
return {
|
|
47
|
+
"type": "thinking",
|
|
48
|
+
"thinking": thinking,
|
|
49
|
+
"signature": f"ciel-runtime-ollama-thinking-{digest}",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ollama_reasoning_only_notice(done_reason: str) -> str:
|
|
54
|
+
if done_reason == "length":
|
|
55
|
+
return (
|
|
56
|
+
"[ciel-runtime] Upstream model exhausted its output budget during reasoning "
|
|
57
|
+
"before producing text or a tool call. Retry or lower reasoning effort."
|
|
58
|
+
)
|
|
59
|
+
return (
|
|
60
|
+
"[ciel-runtime] Upstream model returned reasoning without a final answer or tool call. "
|
|
61
|
+
"Please retry or ask me to continue."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
36
65
|
def encode_anthropic_message(
|
|
37
66
|
*,
|
|
38
67
|
message_id: str,
|
|
@@ -108,4 +137,6 @@ __all__ = [
|
|
|
108
137
|
"decode_ollama_chat_response",
|
|
109
138
|
"encode_anthropic_message",
|
|
110
139
|
"ollama_claude_code_reminder",
|
|
140
|
+
"ollama_reasoning_only_notice",
|
|
141
|
+
"ollama_thinking_to_anthropic_block",
|
|
111
142
|
]
|
|
@@ -9,6 +9,8 @@ from typing import Any, Callable
|
|
|
9
9
|
@dataclass(frozen=True, slots=True)
|
|
10
10
|
class OllamaResponseText:
|
|
11
11
|
decode: Callable[[dict[str, Any]], Any]
|
|
12
|
+
thinking_to_block: Callable[[Any], dict[str, Any] | None]
|
|
13
|
+
reasoning_only_notice: Callable[[str], str]
|
|
12
14
|
strip_thinking: Callable[[str], str]
|
|
13
15
|
parse_pseudo_tools: Callable[..., tuple[str, list[dict[str, Any]]]]
|
|
14
16
|
log: Callable[[str, str], Any]
|
|
@@ -22,6 +24,7 @@ class OllamaResponseTools:
|
|
|
22
24
|
plan_mode_name: Callable[..., tuple[str | None, dict[str, Any]]]
|
|
23
25
|
cap_notification_wait: Callable[..., dict[str, Any]]
|
|
24
26
|
should_drop: Callable[..., bool]
|
|
27
|
+
should_drop_duplicate: Callable[..., bool]
|
|
25
28
|
append_log: Callable[..., Any]
|
|
26
29
|
|
|
27
30
|
|
|
@@ -60,6 +63,9 @@ def project_ollama_response(
|
|
|
60
63
|
) -> dict[str, Any]:
|
|
61
64
|
decoded = services.text.decode(data)
|
|
62
65
|
content: list[dict[str, Any]] = []
|
|
66
|
+
thinking_block = services.text.thinking_to_block(decoded.thinking)
|
|
67
|
+
if thinking_block is not None:
|
|
68
|
+
content.append(thinking_block)
|
|
63
69
|
raw_text = decoded.text
|
|
64
70
|
text = services.text.strip_thinking(raw_text)
|
|
65
71
|
if text != raw_text:
|
|
@@ -71,25 +77,47 @@ def project_ollama_response(
|
|
|
71
77
|
if text:
|
|
72
78
|
content.append({"type": "text", "text": text})
|
|
73
79
|
tool_id_prefix = f"toolu_ollama_{services.output.timestamp_ms()}_{services.output.process_id()}"
|
|
80
|
+
repeated_completed_tool_dropped = [False]
|
|
74
81
|
for index, call in enumerate(list(decoded.tool_calls) + pseudo_tool_calls):
|
|
75
|
-
tool_block = _project_tool_call(
|
|
82
|
+
tool_block = _project_tool_call(
|
|
83
|
+
call,
|
|
84
|
+
index,
|
|
85
|
+
tool_id_prefix,
|
|
86
|
+
model,
|
|
87
|
+
source_body,
|
|
88
|
+
services,
|
|
89
|
+
repeated_completed_tool_dropped,
|
|
90
|
+
)
|
|
76
91
|
if tool_block is not None:
|
|
77
92
|
content.append(tool_block)
|
|
78
93
|
|
|
79
94
|
emitted = [block for block in content if block.get("type") == "tool_use"]
|
|
80
|
-
|
|
95
|
+
if repeated_completed_tool_dropped[0] and not text.strip() and not emitted:
|
|
96
|
+
text = (
|
|
97
|
+
"[ciel-runtime] Stopped an identical completed tool call from repeating. "
|
|
98
|
+
"The previous result is already in context; choose a different action or finish the turn."
|
|
99
|
+
)
|
|
100
|
+
content.append({"type": "text", "text": text})
|
|
101
|
+
recovered = None
|
|
102
|
+
if thinking_block is None and not repeated_completed_tool_dropped[0]:
|
|
103
|
+
recovered = _recover_response(model, source_body, text, emitted, content, tool_id_prefix, services)
|
|
81
104
|
if recovered is not None:
|
|
82
105
|
return recovered
|
|
83
106
|
if source_body is not None and not text.strip() and not emitted:
|
|
84
|
-
text =
|
|
107
|
+
text = (
|
|
108
|
+
services.text.reasoning_only_notice(decoded.done_reason)
|
|
109
|
+
if thinking_block is not None
|
|
110
|
+
else services.recovery.empty_notice(source_body)
|
|
111
|
+
)
|
|
85
112
|
names = ",".join(services.recovery.latest_tool_result_names(source_body)) or "-"
|
|
86
|
-
|
|
113
|
+
event = "ollama_reasoning_only_notice" if thinking_block is not None else "ollama_empty_end_turn_notice"
|
|
114
|
+
services.text.log("WARN", f"{event} model={model} latest_tool_results={names}")
|
|
87
115
|
content.append({"type": "text", "text": text})
|
|
88
116
|
|
|
89
117
|
input_tokens = decoded.input_tokens
|
|
90
118
|
if input_tokens <= 0 and isinstance(source_body, dict):
|
|
91
119
|
input_tokens = services.output.estimate_tokens(source_body)
|
|
92
|
-
output_tokens = decoded.output_tokens or max(1, len(text) // 4)
|
|
120
|
+
output_tokens = decoded.output_tokens or max(1, (len(decoded.thinking) + len(text)) // 4)
|
|
93
121
|
return services.output.encode_message(
|
|
94
122
|
message_id=f"msg_ollama_{services.output.timestamp_ms()}",
|
|
95
123
|
model=model,
|
|
@@ -147,6 +175,26 @@ def project_openai_chat_response(
|
|
|
147
175
|
source_body,
|
|
148
176
|
services,
|
|
149
177
|
)
|
|
178
|
+
cache_hit = positive_int(usage.get("prompt_cache_hit_tokens"))
|
|
179
|
+
cache_miss = positive_int(usage.get("prompt_cache_miss_tokens"))
|
|
180
|
+
details = usage.get("prompt_tokens_details")
|
|
181
|
+
if cache_hit is None and isinstance(details, dict):
|
|
182
|
+
cache_hit = positive_int(details.get("cached_tokens"))
|
|
183
|
+
cache_write = positive_int(usage.get("cache_creation_input_tokens"))
|
|
184
|
+
if cache_write is None and isinstance(details, dict):
|
|
185
|
+
cache_write = positive_int(details.get("cache_write_tokens"))
|
|
186
|
+
if cache_hit is not None or cache_write is not None:
|
|
187
|
+
prompt_tokens = positive_int(usage.get("prompt_tokens")) or 0
|
|
188
|
+
uncached = cache_miss if cache_miss is not None else max(
|
|
189
|
+
0, prompt_tokens - (cache_hit or 0) - (cache_write or 0)
|
|
190
|
+
)
|
|
191
|
+
projected_usage = dict(output.get("usage") or {})
|
|
192
|
+
projected_usage["input_tokens"] = uncached
|
|
193
|
+
if cache_hit is not None:
|
|
194
|
+
projected_usage["cache_read_input_tokens"] = cache_hit
|
|
195
|
+
if cache_write is not None:
|
|
196
|
+
projected_usage["cache_creation_input_tokens"] = cache_write
|
|
197
|
+
output = {**output, "usage": projected_usage}
|
|
150
198
|
thinking_block = reasoning_to_block(message.get("reasoning_content"))
|
|
151
199
|
if thinking_block is None:
|
|
152
200
|
return output
|
|
@@ -165,6 +213,7 @@ def _project_tool_call(
|
|
|
165
213
|
model: str,
|
|
166
214
|
source_body: dict[str, Any] | None,
|
|
167
215
|
services: OllamaResponseServices,
|
|
216
|
+
repeated_completed_tool_dropped: list[bool],
|
|
168
217
|
) -> dict[str, Any] | None:
|
|
169
218
|
function = call.get("function") if isinstance(call, dict) else {}
|
|
170
219
|
if not isinstance(function, dict) or not function.get("name"):
|
|
@@ -181,6 +230,9 @@ def _project_tool_call(
|
|
|
181
230
|
tool_input = services.tools.cap_notification_wait(name, tool_input)
|
|
182
231
|
if services.tools.should_drop(name, tool_input, raw_name, source_body):
|
|
183
232
|
return None
|
|
233
|
+
if services.tools.should_drop_duplicate(name, tool_input, raw_name, source_body):
|
|
234
|
+
repeated_completed_tool_dropped[0] = True
|
|
235
|
+
return None
|
|
184
236
|
services.tools.append_log(
|
|
185
237
|
"ollama_nonstream_tool_call",
|
|
186
238
|
{
|
|
@@ -47,7 +47,7 @@ class OpenAiReasoningPolicy:
|
|
|
47
47
|
self, provider: str, model: str | None, config: dict[str, Any]
|
|
48
48
|
) -> bool:
|
|
49
49
|
adapter = self.adapter_for(provider, config)
|
|
50
|
-
return adapter.
|
|
50
|
+
return adapter.reasoning_passback_enabled(
|
|
51
51
|
self.config_for(provider, config), model
|
|
52
52
|
)
|
|
53
53
|
|
|
@@ -65,7 +65,10 @@ class OpenAiReasoningPolicy:
|
|
|
65
65
|
) -> bool:
|
|
66
66
|
if body.get("tool_choice") is None:
|
|
67
67
|
return False
|
|
68
|
-
|
|
68
|
+
adapter = self.adapter_for(provider, config)
|
|
69
|
+
return adapter.should_omit_openai_tool_choice(
|
|
70
|
+
self.config_for(provider, config), model, body
|
|
71
|
+
)
|
|
69
72
|
|
|
70
73
|
|
|
71
74
|
__all__ = [
|
|
@@ -11,6 +11,7 @@ import uuid
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from ..architecture import MessageProtocolAdapter
|
|
14
|
+
from ..responses_input_compatibility import router_synthesized_item_id
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _positive_int(value: Any) -> int | None:
|
|
@@ -54,6 +55,17 @@ def _content_blocks(content: Any) -> list[dict[str, Any]]:
|
|
|
54
55
|
return [{"type": "text", "text": text}] if text else []
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
def _reasoning_summary_text(item: dict[str, Any]) -> str:
|
|
59
|
+
summary = item.get("summary")
|
|
60
|
+
if not isinstance(summary, list):
|
|
61
|
+
return ""
|
|
62
|
+
return "\n".join(
|
|
63
|
+
str(part.get("text") or "")
|
|
64
|
+
for part in summary
|
|
65
|
+
if isinstance(part, dict) and part.get("type") == "summary_text"
|
|
66
|
+
).strip()
|
|
67
|
+
|
|
68
|
+
|
|
57
69
|
def _tools_to_anthropic(tools: Any) -> list[dict[str, Any]]:
|
|
58
70
|
out: list[dict[str, Any]] = []
|
|
59
71
|
if not isinstance(tools, list):
|
|
@@ -117,25 +129,36 @@ def openai_responses_to_anthropic_messages(body: dict[str, Any], fallback_model:
|
|
|
117
129
|
raw_input = [raw_input]
|
|
118
130
|
if not isinstance(raw_input, list):
|
|
119
131
|
raw_input = []
|
|
132
|
+
saw_conversation_item = False
|
|
133
|
+
pending_reasoning = ""
|
|
120
134
|
for item in raw_input:
|
|
121
135
|
if not isinstance(item, dict):
|
|
122
136
|
continue
|
|
123
137
|
item_type = str(item.get("type") or "message")
|
|
138
|
+
if item_type == "reasoning":
|
|
139
|
+
pending_reasoning = _reasoning_summary_text(item)
|
|
140
|
+
continue
|
|
124
141
|
if item_type == "function_call":
|
|
125
142
|
call_id = str(item.get("call_id") or item.get("id") or f"call_{len(messages) + 1}")
|
|
143
|
+
content: list[dict[str, Any]] = []
|
|
144
|
+
if pending_reasoning:
|
|
145
|
+
content.append({"type": "thinking", "thinking": pending_reasoning})
|
|
146
|
+
pending_reasoning = ""
|
|
147
|
+
content.append(
|
|
148
|
+
{
|
|
149
|
+
"type": "tool_use",
|
|
150
|
+
"id": call_id,
|
|
151
|
+
"name": str(item.get("name") or "tool"),
|
|
152
|
+
"input": _json_object(item.get("arguments")),
|
|
153
|
+
}
|
|
154
|
+
)
|
|
126
155
|
messages.append(
|
|
127
156
|
{
|
|
128
157
|
"role": "assistant",
|
|
129
|
-
"content":
|
|
130
|
-
{
|
|
131
|
-
"type": "tool_use",
|
|
132
|
-
"id": call_id,
|
|
133
|
-
"name": str(item.get("name") or "tool"),
|
|
134
|
-
"input": _json_object(item.get("arguments")),
|
|
135
|
-
}
|
|
136
|
-
],
|
|
158
|
+
"content": content,
|
|
137
159
|
}
|
|
138
160
|
)
|
|
161
|
+
saw_conversation_item = True
|
|
139
162
|
continue
|
|
140
163
|
if item_type == "function_call_output":
|
|
141
164
|
call_id = str(item.get("call_id") or item.get("id") or "call_tool")
|
|
@@ -151,17 +174,22 @@ def openai_responses_to_anthropic_messages(body: dict[str, Any], fallback_model:
|
|
|
151
174
|
],
|
|
152
175
|
}
|
|
153
176
|
)
|
|
177
|
+
saw_conversation_item = True
|
|
154
178
|
continue
|
|
155
179
|
role = str(item.get("role") or "user").strip().lower()
|
|
156
180
|
blocks = _content_blocks(item.get("content", item.get("text", "")))
|
|
157
181
|
if not blocks:
|
|
158
182
|
continue
|
|
159
|
-
if role in ("system", "developer"):
|
|
183
|
+
if role in ("system", "developer") and not saw_conversation_item:
|
|
160
184
|
system_parts.append(_content_text(blocks))
|
|
161
185
|
continue
|
|
186
|
+
if role in ("system", "developer"):
|
|
187
|
+
role = "user"
|
|
188
|
+
blocks = [{"type": "text", "text": f"[Runtime system context]\n{_content_text(blocks)}"}]
|
|
162
189
|
if role not in ("user", "assistant"):
|
|
163
190
|
role = "user"
|
|
164
191
|
messages.append({"role": role, "content": blocks})
|
|
192
|
+
saw_conversation_item = True
|
|
165
193
|
if not messages:
|
|
166
194
|
messages.append({"role": "user", "content": [{"type": "text", "text": ""}]})
|
|
167
195
|
out: dict[str, Any] = {
|
|
@@ -180,21 +208,29 @@ def openai_responses_to_anthropic_messages(body: dict[str, Any], fallback_model:
|
|
|
180
208
|
out["max_tokens"] = max_tokens
|
|
181
209
|
reasoning = body.get("reasoning")
|
|
182
210
|
if isinstance(reasoning, dict) and reasoning.get("effort") is not None:
|
|
211
|
+
effort = str(reasoning["effort"])
|
|
183
212
|
out["thinking"] = {
|
|
184
|
-
"type": "enabled",
|
|
185
|
-
"effort":
|
|
213
|
+
"type": "disabled" if effort.strip().lower() in {"none", "minimal"} else "enabled",
|
|
214
|
+
"effort": effort,
|
|
186
215
|
}
|
|
187
216
|
if system_parts:
|
|
188
217
|
out["system"] = [{"type": "text", "text": part} for part in system_parts if part]
|
|
189
218
|
return out
|
|
190
219
|
|
|
191
220
|
|
|
192
|
-
def _usage_from_anthropic(message: dict[str, Any]) -> dict[str,
|
|
221
|
+
def _usage_from_anthropic(message: dict[str, Any]) -> dict[str, Any]:
|
|
193
222
|
usage = message.get("usage") if isinstance(message.get("usage"), dict) else {}
|
|
194
|
-
|
|
223
|
+
uncached_input = _positive_int(usage.get("input_tokens")) or 0
|
|
224
|
+
cached_input = _positive_int(usage.get("cache_read_input_tokens")) or 0
|
|
225
|
+
cache_write = _positive_int(usage.get("cache_creation_input_tokens")) or 0
|
|
226
|
+
input_tokens = uncached_input + cached_input + cache_write
|
|
195
227
|
output_tokens = _positive_int(usage.get("output_tokens")) or 0
|
|
196
228
|
return {
|
|
197
229
|
"input_tokens": input_tokens,
|
|
230
|
+
"input_tokens_details": {
|
|
231
|
+
"cached_tokens": cached_input,
|
|
232
|
+
"cache_write_tokens": cache_write,
|
|
233
|
+
},
|
|
198
234
|
"output_tokens": output_tokens,
|
|
199
235
|
"total_tokens": input_tokens + output_tokens,
|
|
200
236
|
}
|
|
@@ -215,7 +251,7 @@ def anthropic_message_to_openai_response(
|
|
|
215
251
|
text = str(block.get("text") or "")
|
|
216
252
|
output.append(
|
|
217
253
|
{
|
|
218
|
-
"id":
|
|
254
|
+
"id": router_synthesized_item_id("msg", response_id, index),
|
|
219
255
|
"type": "message",
|
|
220
256
|
"status": "completed",
|
|
221
257
|
"role": "assistant",
|
|
@@ -226,7 +262,7 @@ def anthropic_message_to_openai_response(
|
|
|
226
262
|
call_id = str(block.get("id") or f"call_{index + 1}")
|
|
227
263
|
output.append(
|
|
228
264
|
{
|
|
229
|
-
"id":
|
|
265
|
+
"id": router_synthesized_item_id("fc", response_id, index),
|
|
230
266
|
"type": "function_call",
|
|
231
267
|
"status": "completed",
|
|
232
268
|
"call_id": call_id,
|
|
@@ -234,6 +270,16 @@ def anthropic_message_to_openai_response(
|
|
|
234
270
|
"arguments": json.dumps(block.get("input") or {}, ensure_ascii=False),
|
|
235
271
|
}
|
|
236
272
|
)
|
|
273
|
+
elif block_type == "thinking":
|
|
274
|
+
thinking = str(block.get("thinking") or "")
|
|
275
|
+
if thinking:
|
|
276
|
+
output.append(
|
|
277
|
+
{
|
|
278
|
+
"id": router_synthesized_item_id("rs", response_id, index),
|
|
279
|
+
"type": "reasoning",
|
|
280
|
+
"summary": [{"type": "summary_text", "text": thinking}],
|
|
281
|
+
}
|
|
282
|
+
)
|
|
237
283
|
return {
|
|
238
284
|
"id": response_id,
|
|
239
285
|
"object": "response",
|
|
@@ -28,6 +28,11 @@ from .providers.zai import ZaiProviderAdapter
|
|
|
28
28
|
from .providers.kimi import KimiProviderAdapter
|
|
29
29
|
from .providers.fireworks import FireworksProviderAdapter
|
|
30
30
|
from .providers.meta import MetaModelProviderAdapter
|
|
31
|
+
from .providers.alibaba import (
|
|
32
|
+
AlibabaIndividualTokenPlanProviderAdapter,
|
|
33
|
+
AlibabaModelStudioProviderAdapter,
|
|
34
|
+
AlibabaTokenPlanProviderAdapter,
|
|
35
|
+
)
|
|
31
36
|
from .providers.github_copilot_oauth import (
|
|
32
37
|
GitHubCopilotOAuthProviderAdapter,
|
|
33
38
|
)
|
|
@@ -65,6 +70,24 @@ PROVIDER_DESCRIPTORS = ProviderDescriptorRegistry(
|
|
|
65
70
|
MetaModelProviderAdapter,
|
|
66
71
|
aliases=("muse", "muse-spark", "meta-model-api"),
|
|
67
72
|
),
|
|
73
|
+
ProviderDescriptor(
|
|
74
|
+
"alims-intl",
|
|
75
|
+
"Alibaba Model Studio International",
|
|
76
|
+
AlibabaModelStudioProviderAdapter,
|
|
77
|
+
aliases=("dashscope-intl",),
|
|
78
|
+
),
|
|
79
|
+
ProviderDescriptor(
|
|
80
|
+
"alitoken",
|
|
81
|
+
"Alibaba Model Studio Token Plan (Singapore)",
|
|
82
|
+
AlibabaTokenPlanProviderAdapter,
|
|
83
|
+
aliases=("alibaba-token", "alibaba-token-plan", "bailian-token"),
|
|
84
|
+
),
|
|
85
|
+
ProviderDescriptor(
|
|
86
|
+
"alitoken-individual",
|
|
87
|
+
"Alibaba Token Plan Individual",
|
|
88
|
+
AlibabaIndividualTokenPlanProviderAdapter,
|
|
89
|
+
aliases=("alibaba-token-individual", "bailian-token-individual"),
|
|
90
|
+
),
|
|
68
91
|
ProviderDescriptor(
|
|
69
92
|
"github-copilot-oauth",
|
|
70
93
|
"GitHub Copilot OAuth",
|
|
@@ -147,6 +170,9 @@ __all__ = [
|
|
|
147
170
|
"GitHubCopilotOAuthProviderAdapter",
|
|
148
171
|
"LMStudioProviderAdapter",
|
|
149
172
|
"MetaModelProviderAdapter",
|
|
173
|
+
"AlibabaModelStudioProviderAdapter",
|
|
174
|
+
"AlibabaIndividualTokenPlanProviderAdapter",
|
|
175
|
+
"AlibabaTokenPlanProviderAdapter",
|
|
150
176
|
"NvidiaHostedProviderAdapter",
|
|
151
177
|
"OllamaCloudProviderAdapter",
|
|
152
178
|
"OllamaProviderAdapter",
|