@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.
Files changed (126) hide show
  1. package/ciel_runtime.py +2553 -9635
  2. package/ciel_runtime_support/advisor_request_builder.py +8 -21
  3. package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
  4. package/ciel_runtime_support/architecture.py +68 -0
  5. package/ciel_runtime_support/architecture_budget.py +1 -1
  6. package/ciel_runtime_support/channel_connection_context.py +233 -0
  7. package/ciel_runtime_support/channel_delivery_context.py +332 -0
  8. package/ciel_runtime_support/channel_mcp_context.py +313 -0
  9. package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
  10. package/ciel_runtime_support/channel_mcp_transport.py +5 -1
  11. package/ciel_runtime_support/channel_message_context.py +212 -0
  12. package/ciel_runtime_support/channel_message_repository.py +14 -3
  13. package/ciel_runtime_support/channel_pending_injection.py +9 -0
  14. package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
  15. package/ciel_runtime_support/channel_replay_policy.py +38 -0
  16. package/ciel_runtime_support/channel_runtime_environment.py +8 -0
  17. package/ciel_runtime_support/channel_session_context.py +236 -0
  18. package/ciel_runtime_support/channel_terminal_context.py +350 -0
  19. package/ciel_runtime_support/channel_wake_context.py +532 -0
  20. package/ciel_runtime_support/claude_environment.py +60 -0
  21. package/ciel_runtime_support/claude_launch_assembly.py +249 -0
  22. package/ciel_runtime_support/claude_router.py +62 -12
  23. package/ciel_runtime_support/cli_application_context.py +132 -0
  24. package/ciel_runtime_support/cli_assembly.py +50 -0
  25. package/ciel_runtime_support/codex_backend_context.py +363 -0
  26. package/ciel_runtime_support/codex_config.py +13 -1
  27. package/ciel_runtime_support/codex_launch_assembly.py +213 -0
  28. package/ciel_runtime_support/codex_launch_configuration.py +30 -1
  29. package/ciel_runtime_support/codex_mcp_integration.py +90 -8
  30. package/ciel_runtime_support/codex_model_catalog.py +4 -1
  31. package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
  32. package/ciel_runtime_support/codex_router.py +38 -8
  33. package/ciel_runtime_support/codex_turn_recovery.py +154 -0
  34. package/ciel_runtime_support/config_migrations.py +103 -0
  35. package/ciel_runtime_support/configuration_cli.py +38 -0
  36. package/ciel_runtime_support/context_compaction.py +9 -4
  37. package/ciel_runtime_support/credential_management.py +12 -0
  38. package/ciel_runtime_support/credentials.py +12 -0
  39. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  40. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  41. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  42. package/ciel_runtime_support/llm_preset_context.py +338 -0
  43. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  44. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  45. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  46. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  47. package/ciel_runtime_support/mcp_transport.py +14 -1
  48. package/ciel_runtime_support/native_context_recovery.py +72 -0
  49. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  50. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  51. package/ciel_runtime_support/ollama_thinking.py +6 -1
  52. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  53. package/ciel_runtime_support/openai_forwarding.py +32 -10
  54. package/ciel_runtime_support/openai_responses_router.py +12 -0
  55. package/ciel_runtime_support/package_lifecycle.py +39 -0
  56. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  57. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  58. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  59. package/ciel_runtime_support/prompt_compaction.py +144 -0
  60. package/ciel_runtime_support/prompt_injection.py +45 -0
  61. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  62. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  63. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  64. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  65. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  66. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  67. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  68. package/ciel_runtime_support/provider_adapters.py +26 -0
  69. package/ciel_runtime_support/provider_administration_context.py +207 -0
  70. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  71. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  72. package/ciel_runtime_support/provider_model_context.py +107 -0
  73. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  74. package/ciel_runtime_support/provider_model_selection.py +10 -3
  75. package/ciel_runtime_support/provider_models.py +45 -2
  76. package/ciel_runtime_support/provider_option_cli.py +19 -0
  77. package/ciel_runtime_support/provider_policy.py +1 -1
  78. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  79. package/ciel_runtime_support/provider_request_builder.py +64 -28
  80. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  81. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  82. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  83. package/ciel_runtime_support/providers/__init__.py +6 -0
  84. package/ciel_runtime_support/providers/alibaba.py +634 -0
  85. package/ciel_runtime_support/providers/catalog.py +24 -16
  86. package/ciel_runtime_support/providers/deepseek.py +73 -0
  87. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  88. package/ciel_runtime_support/providers/kimi.py +69 -9
  89. package/ciel_runtime_support/providers/ollama.py +8 -0
  90. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  91. package/ciel_runtime_support/providers/vllm.py +7 -1
  92. package/ciel_runtime_support/response_collection.py +68 -18
  93. package/ciel_runtime_support/response_collection_context.py +391 -0
  94. package/ciel_runtime_support/response_stream_context.py +555 -0
  95. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  96. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  97. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  98. package/ciel_runtime_support/router_http.py +239 -3
  99. package/ciel_runtime_support/router_observability_context.py +251 -0
  100. package/ciel_runtime_support/router_process_context.py +200 -0
  101. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  102. package/ciel_runtime_support/router_request_assembly.py +399 -0
  103. package/ciel_runtime_support/router_request_context.py +215 -0
  104. package/ciel_runtime_support/router_server_context.py +82 -0
  105. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  106. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  107. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  108. package/ciel_runtime_support/runtime_constants.py +16 -1
  109. package/ciel_runtime_support/runtime_launch.py +9 -5
  110. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  111. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  112. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  113. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  114. package/ciel_runtime_support/runtime_paths.py +60 -40
  115. package/ciel_runtime_support/runtime_primitives.py +78 -0
  116. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  117. package/ciel_runtime_support/statusline_script.py +57 -8
  118. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  119. package/ciel_runtime_support/tool_schema.py +40 -2
  120. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  121. package/ciel_runtime_support/upstream_dump.py +68 -0
  122. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  123. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  124. package/docs/Configuration.md +50 -0
  125. package/docs/Test-Suite.md +1 -0
  126. 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
- for message in body.get("messages", []) or []:
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": text.compact_text(text.content_to_text(text_blocks))}
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
- user_text = text.content_to_text(text_blocks)
211
- if user_text:
212
- messages.append({"role": "user", "content": text.compact_text(user_text)})
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(call, index, tool_id_prefix, model, source_body, services)
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
- recovered = _recover_response(model, source_body, text, emitted, content, tool_id_prefix, services)
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 = services.recovery.empty_notice(source_body)
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
- services.text.log("WARN", f"ollama_empty_end_turn_notice model={model} latest_tool_results={names}")
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.openai_reasoning_passback_enabled(
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
- return self.passback_enabled(provider, model, config)
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": str(reasoning["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, int]:
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
- input_tokens = _positive_int(usage.get("input_tokens")) or 0
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": f"msg_{response_id[5:13]}_{index}",
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": f"fc_{response_id[5:13]}_{index}",
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",