@oneciel-ai/ciel-runtime 0.2.2 → 0.2.4

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 (136) hide show
  1. package/ciel_runtime.py +2555 -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/config_repository.py +30 -0
  36. package/ciel_runtime_support/configuration_cli.py +38 -0
  37. package/ciel_runtime_support/context_compaction.py +9 -4
  38. package/ciel_runtime_support/credential_management.py +12 -0
  39. package/ciel_runtime_support/credentials.py +12 -0
  40. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  41. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  42. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  43. package/ciel_runtime_support/llm_preset_context.py +338 -0
  44. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  45. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  46. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  47. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  48. package/ciel_runtime_support/mcp_transport.py +14 -1
  49. package/ciel_runtime_support/native_context_recovery.py +72 -0
  50. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  51. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  52. package/ciel_runtime_support/ollama_thinking.py +6 -1
  53. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  54. package/ciel_runtime_support/openai_forwarding.py +32 -10
  55. package/ciel_runtime_support/openai_responses_router.py +12 -0
  56. package/ciel_runtime_support/package_lifecycle.py +39 -0
  57. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  58. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  59. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  60. package/ciel_runtime_support/prompt_compaction.py +144 -0
  61. package/ciel_runtime_support/prompt_injection.py +45 -0
  62. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  63. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  64. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  65. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  66. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  67. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  68. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  69. package/ciel_runtime_support/provider_adapters.py +26 -0
  70. package/ciel_runtime_support/provider_administration_context.py +207 -0
  71. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  72. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  73. package/ciel_runtime_support/provider_model_context.py +107 -0
  74. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  75. package/ciel_runtime_support/provider_model_selection.py +10 -3
  76. package/ciel_runtime_support/provider_models.py +45 -2
  77. package/ciel_runtime_support/provider_option_cli.py +19 -0
  78. package/ciel_runtime_support/provider_policy.py +1 -1
  79. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  80. package/ciel_runtime_support/provider_request_builder.py +64 -28
  81. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  82. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  83. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  84. package/ciel_runtime_support/providers/__init__.py +6 -0
  85. package/ciel_runtime_support/providers/alibaba.py +634 -0
  86. package/ciel_runtime_support/providers/catalog.py +24 -16
  87. package/ciel_runtime_support/providers/deepseek.py +73 -0
  88. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  89. package/ciel_runtime_support/providers/kimi.py +69 -9
  90. package/ciel_runtime_support/providers/ollama.py +8 -0
  91. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  92. package/ciel_runtime_support/providers/vllm.py +7 -1
  93. package/ciel_runtime_support/response_collection.py +68 -18
  94. package/ciel_runtime_support/response_collection_context.py +391 -0
  95. package/ciel_runtime_support/response_stream_context.py +555 -0
  96. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  97. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  98. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  99. package/ciel_runtime_support/router_http.py +245 -3
  100. package/ciel_runtime_support/router_observability_context.py +251 -0
  101. package/ciel_runtime_support/router_process_context.py +200 -0
  102. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  103. package/ciel_runtime_support/router_request_assembly.py +399 -0
  104. package/ciel_runtime_support/router_request_context.py +215 -0
  105. package/ciel_runtime_support/router_server_context.py +84 -0
  106. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  107. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  108. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  109. package/ciel_runtime_support/runtime_constants.py +16 -1
  110. package/ciel_runtime_support/runtime_launch.py +9 -5
  111. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  112. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  113. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  114. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  115. package/ciel_runtime_support/runtime_paths.py +60 -40
  116. package/ciel_runtime_support/runtime_primitives.py +78 -0
  117. package/ciel_runtime_support/speech_http_controller.py +335 -0
  118. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  119. package/ciel_runtime_support/statusline_script.py +57 -8
  120. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  121. package/ciel_runtime_support/tool_schema.py +40 -2
  122. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  123. package/ciel_runtime_support/upstream_dump.py +68 -0
  124. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  125. package/ciel_runtime_support/web_ui.py +248 -1
  126. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  127. package/docs/COLAB_SPEECH.md +32 -0
  128. package/docs/Configuration.md +50 -0
  129. package/docs/Test-Suite.md +1 -0
  130. package/package.json +4 -1
  131. package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
  132. package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
  133. package/scripts/colab/bootstrap_moss_tts.py +106 -0
  134. package/scripts/colab/bootstrap_qwen_asr.py +106 -0
  135. package/scripts/configure_speech_workers.py +37 -0
  136. package/scripts/deploy_colab_speech.ps1 +47 -0
@@ -0,0 +1,213 @@
1
+ """Ollama catalog persistence and context-metadata bounded context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from . import ollama_catalog as policy
10
+ from .ollama_catalog_repository import OllamaCatalogRepository
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class OllamaCatalogRepositoryPorts:
15
+ path: Path
16
+ log: Callable[[str, str], None]
17
+ with_user_agent: Callable[[dict[str, str] | None], dict[str, str]]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class OllamaCatalogProjectionPorts:
22
+ normalize_model: Callable[[str, str], str]
23
+ unique_models: Callable[[list[str]], list[str]]
24
+ sorted_models: Callable[[list[str]], list[str]]
25
+ model_lookup_ids: Callable[[str], list[str]]
26
+ positive_int: Callable[[Any], int | None]
27
+ catalog_url: str
28
+ default_ttl_seconds: int
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class OllamaCatalogWorkflowPorts:
33
+ load: Callable[[], dict[str, Any]]
34
+ save: Callable[[dict[str, Any]], None]
35
+ fetch_json: Callable[[str, float], Any]
36
+ fetch_context_map: Callable[[str, float], tuple[dict[str, int], str | None]]
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class OllamaCatalogContext:
41
+ repository_ports: OllamaCatalogRepositoryPorts
42
+ projection: OllamaCatalogProjectionPorts
43
+ workflow: OllamaCatalogWorkflowPorts
44
+
45
+ def repository(self) -> OllamaCatalogRepository:
46
+ return OllamaCatalogRepository(
47
+ self.repository_ports.path,
48
+ self.repository_ports.log,
49
+ self.repository_ports.with_user_agent,
50
+ )
51
+
52
+ def load(self) -> dict[str, Any]:
53
+ return self.repository().load()
54
+
55
+ def save(self, catalog: dict[str, Any]) -> None:
56
+ self.repository().save(catalog)
57
+
58
+ def model_ids(
59
+ self,
60
+ provider: str = "ollama-cloud",
61
+ catalog: dict[str, Any] | None = None,
62
+ ) -> list[str]:
63
+ source = catalog if isinstance(catalog, dict) else self.workflow.load()
64
+ return policy.catalog_model_ids(
65
+ source,
66
+ provider,
67
+ normalize_model_id=self.projection.normalize_model,
68
+ unique_model_ids=self.projection.unique_models,
69
+ sorted_model_ids=self.projection.sorted_models,
70
+ )
71
+
72
+ def is_stale(
73
+ self, catalog: dict[str, Any], ttl_seconds: int | None = None
74
+ ) -> bool:
75
+ ttl = (
76
+ self.projection.default_ttl_seconds
77
+ if ttl_seconds is None
78
+ else ttl_seconds
79
+ )
80
+ return policy.catalog_is_stale(catalog, ttl)
81
+
82
+ def fetch_json(self, url: str, timeout: float = 12.0) -> Any:
83
+ return self.repository().fetch_json(url, timeout)
84
+
85
+ def fetch_context_map(
86
+ self, base_model: str, timeout: float = 10.0
87
+ ) -> tuple[dict[str, int], str | None]:
88
+ return self.repository().fetch_library_context_map(base_model, timeout)
89
+
90
+ def refresh(
91
+ self, include_contexts: bool = True, timeout: float = 10.0
92
+ ) -> dict[str, Any]:
93
+ return policy.refresh_model_catalog(
94
+ policy.OllamaCatalogRefreshServices(
95
+ load_catalog=self.workflow.load,
96
+ fetch_catalog=self.workflow.fetch_json,
97
+ fetch_context_map=self.workflow.fetch_context_map,
98
+ save_catalog=self.workflow.save,
99
+ positive_int=self.projection.positive_int,
100
+ ),
101
+ include_contexts=include_contexts,
102
+ timeout=timeout,
103
+ catalog_url=self.projection.catalog_url,
104
+ )
105
+
106
+ def context_for_model(
107
+ self, model_id: str
108
+ ) -> tuple[int | None, str | None, str | None]:
109
+ return policy.catalog_context_for_model(
110
+ self.workflow.load(), model_id, self.projection.model_lookup_ids
111
+ )
112
+
113
+ def timeout_for_model(self, model_id: str) -> int | None:
114
+ return policy.catalog_timeout_for_model(
115
+ self.workflow.load(), model_id, self.projection.model_lookup_ids
116
+ )
117
+
118
+ def update_context(
119
+ self,
120
+ model_id: str,
121
+ limit: int,
122
+ matched_model: str | None,
123
+ source_url: str | None,
124
+ ) -> None:
125
+ self.workflow.save(
126
+ policy.with_updated_context(
127
+ self.workflow.load(),
128
+ model_id,
129
+ limit,
130
+ matched_model,
131
+ source_url,
132
+ )
133
+ )
134
+
135
+ def fetch_context_limit(
136
+ self, model_id: str, timeout: float = 6.0
137
+ ) -> tuple[int | None, str | None, str | None]:
138
+ return policy.fetch_library_context_limit(
139
+ model_id,
140
+ timeout=timeout,
141
+ fetch_context_map=self.workflow.fetch_context_map,
142
+ positive_int=self.projection.positive_int,
143
+ )
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class OllamaCatalogCompatibilityApi:
148
+ context: Callable[[], OllamaCatalogContext]
149
+
150
+ def repository(self) -> OllamaCatalogRepository:
151
+ return self.context().repository()
152
+
153
+ def load(self) -> dict[str, Any]:
154
+ return self.context().load()
155
+
156
+ def save(self, catalog: dict[str, Any]) -> None:
157
+ self.context().save(catalog)
158
+
159
+ def model_ids(
160
+ self,
161
+ provider: str = "ollama-cloud",
162
+ catalog: dict[str, Any] | None = None,
163
+ ) -> list[str]:
164
+ return self.context().model_ids(provider, catalog)
165
+
166
+ def is_stale(
167
+ self, catalog: dict[str, Any], ttl_seconds: int | None = None
168
+ ) -> bool:
169
+ return self.context().is_stale(catalog, ttl_seconds)
170
+
171
+ def fetch_json(self, url: str, timeout: float = 12.0) -> Any:
172
+ return self.context().fetch_json(url, timeout)
173
+
174
+ def fetch_context_map(
175
+ self, base_model: str, timeout: float = 10.0
176
+ ) -> tuple[dict[str, int], str | None]:
177
+ return self.context().fetch_context_map(base_model, timeout)
178
+
179
+ def refresh(
180
+ self, include_contexts: bool = True, timeout: float = 10.0
181
+ ) -> dict[str, Any]:
182
+ return self.context().refresh(include_contexts, timeout)
183
+
184
+ def context_for_model(
185
+ self, model_id: str
186
+ ) -> tuple[int | None, str | None, str | None]:
187
+ return self.context().context_for_model(model_id)
188
+
189
+ def timeout_for_model(self, model_id: str) -> int | None:
190
+ return self.context().timeout_for_model(model_id)
191
+
192
+ def update_context(
193
+ self,
194
+ model_id: str,
195
+ limit: int,
196
+ matched_model: str | None,
197
+ source_url: str | None,
198
+ ) -> None:
199
+ self.context().update_context(model_id, limit, matched_model, source_url)
200
+
201
+ def fetch_context_limit(
202
+ self, model_id: str, timeout: float = 6.0
203
+ ) -> tuple[int | None, str | None, str | None]:
204
+ return self.context().fetch_context_limit(model_id, timeout)
205
+
206
+
207
+ __all__ = [
208
+ "OllamaCatalogCompatibilityApi",
209
+ "OllamaCatalogContext",
210
+ "OllamaCatalogProjectionPorts",
211
+ "OllamaCatalogRepositoryPorts",
212
+ "OllamaCatalogWorkflowPorts",
213
+ ]
@@ -0,0 +1,103 @@
1
+ """Collect an Ollama chat stream into one response, cutting loops short.
2
+
3
+ The Codex-facing collection path asks providers for a single complete message.
4
+ Asking Ollama for it with ``stream: false`` means a repetition loop is generated
5
+ in full before the router ever sees a character of it -- the reported case ran
6
+ for four minutes and produced 142,635 characters of the same 37-character block.
7
+ Nothing downstream can undo that; the time and the tokens are already spent.
8
+
9
+ Reading the same request as a stream costs nothing extra and makes the loop
10
+ observable while it is still being written, so the guard can close the
11
+ connection after a couple of thousand characters. The assembled result is the
12
+ same envelope ``decode_ollama_chat_response`` already expects, so every stage
13
+ after collection is unchanged.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from typing import Any, Iterable
21
+
22
+ from .runaway_output_guard import (
23
+ RunawayOutputDetector,
24
+ RunawayOutputPolicy,
25
+ RunawayVerdict,
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class OllamaStreamCollection:
31
+ response: dict[str, Any]
32
+ verdict: RunawayVerdict | None = None
33
+ chunks: int = 0
34
+
35
+
36
+ def collect_ollama_chat_stream(
37
+ lines: Iterable[Any], policy: RunawayOutputPolicy | None = None
38
+ ) -> OllamaStreamCollection:
39
+ """Merge Ollama NDJSON chunks into one response envelope.
40
+
41
+ Returns as soon as the guard reports a loop, leaving the rest of the stream
42
+ unread so the caller can close the connection and stop generation.
43
+ """
44
+
45
+ text_runaway = RunawayOutputDetector(policy)
46
+ thinking_runaway = RunawayOutputDetector(policy)
47
+ verdict: RunawayVerdict | None = None
48
+ content: list[str] = []
49
+ thinking: list[str] = []
50
+ tool_calls: list[dict[str, Any]] = []
51
+ response: dict[str, Any] = {}
52
+ chunks = 0
53
+ for raw in lines:
54
+ line = raw.decode("utf-8", errors="ignore").strip() if isinstance(raw, bytes) else str(raw).strip()
55
+ if not line:
56
+ continue
57
+ try:
58
+ chunk = json.loads(line)
59
+ except ValueError:
60
+ continue
61
+ if not isinstance(chunk, dict):
62
+ continue
63
+ chunks += 1
64
+ for key in ("model", "created_at", "done", "done_reason"):
65
+ if chunk.get(key) is not None:
66
+ response[key] = chunk[key]
67
+ for key in ("prompt_eval_count", "eval_count", "total_duration"):
68
+ try:
69
+ value = int(chunk.get(key) or 0)
70
+ except (TypeError, ValueError):
71
+ continue
72
+ if value:
73
+ response[key] = max(int(response.get(key) or 0), value)
74
+ message = chunk.get("message")
75
+ if not isinstance(message, dict):
76
+ continue
77
+ text_chunk = str(message.get("content") or "")
78
+ thinking_chunk = str(message.get("thinking") or "")
79
+ if text_chunk:
80
+ content.append(text_chunk)
81
+ verdict = verdict or text_runaway.feed(text_chunk)
82
+ if thinking_chunk:
83
+ thinking.append(thinking_chunk)
84
+ verdict = verdict or thinking_runaway.feed(thinking_chunk)
85
+ for call in message.get("tool_calls") or []:
86
+ if isinstance(call, dict):
87
+ tool_calls.append(call)
88
+ if verdict is not None:
89
+ break
90
+ collected: dict[str, Any] = {"role": "assistant", "content": "".join(content)}
91
+ if thinking:
92
+ collected["thinking"] = "".join(thinking)
93
+ if tool_calls:
94
+ collected["tool_calls"] = tool_calls
95
+ response["message"] = collected
96
+ response.setdefault("done", True)
97
+ return OllamaStreamCollection(response=response, verdict=verdict, chunks=chunks)
98
+
99
+
100
+ __all__ = [
101
+ "OllamaStreamCollection",
102
+ "collect_ollama_chat_stream",
103
+ ]
@@ -131,8 +131,13 @@ class OllamaThinkingPolicy:
131
131
  str(item).strip().lower()
132
132
  for item in options.get("ollama_model_capabilities") or []
133
133
  }
134
- if "thinking" in capabilities or options.get("think_explicit"):
134
+ if options.get("think_explicit"):
135
135
  return bool(options.get("think", False))
136
+ if "thinking" in capabilities:
137
+ if thinking_disabled(request):
138
+ return False
139
+ if effort:
140
+ return True
136
141
  return None
137
142
 
138
143
 
@@ -0,0 +1,157 @@
1
+ """Project provider-owned Ollama request settings onto wire payloads.
2
+
3
+ The application uses several Ollama request paths (interactive forwarding,
4
+ Advisor, and optional LLM compaction). This policy is their single boundary
5
+ for optional model/runtime settings. It deliberately distinguishes required
6
+ protocol fields from explicit operator overrides so persisted adapter defaults
7
+ do not silently become upstream request parameters.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, Mapping
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class OllamaWireProjectionPorts:
18
+ think_value: Callable[
19
+ [str, str | None, dict[str, Any], Mapping[str, Any]], bool | str | None
20
+ ]
21
+ positive_int: Callable[[Any], int | None]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class OllamaWireProjection:
26
+ ports: OllamaWireProjectionPorts
27
+ legacy_keep_alive_default: str = "5m"
28
+
29
+ @staticmethod
30
+ def _marked_keys(config: Mapping[str, Any], name: str) -> set[str]:
31
+ raw = config.get(name)
32
+ if not isinstance(raw, (list, tuple, set)):
33
+ return set()
34
+ return {str(item) for item in raw if str(item).strip()}
35
+
36
+ def explicit_options(self, config: Mapping[str, Any]) -> dict[str, Any]:
37
+ raw = config.get("ollama_options")
38
+ if not isinstance(raw, Mapping):
39
+ return {}
40
+ allowed = self._marked_keys(
41
+ config, "ollama_explicit_options"
42
+ ) | self._marked_keys(config, "ollama_transient_options")
43
+ return {
44
+ str(key): value
45
+ for key, value in raw.items()
46
+ if str(key) in allowed and value is not None
47
+ }
48
+
49
+ def keep_alive(self, config: Mapping[str, Any]) -> str | None:
50
+ value = config.get("keep_alive")
51
+ if value is None or str(value).strip() == "":
52
+ return None
53
+ if config.get("keep_alive_explicit"):
54
+ return str(value)
55
+ # Before provenance markers existed, any non-default persisted value
56
+ # could only have come from an operator edit. Preserve that intent.
57
+ if str(value) != self.legacy_keep_alive_default:
58
+ return str(value)
59
+ return None
60
+
61
+ def options(
62
+ self,
63
+ config: Mapping[str, Any],
64
+ *,
65
+ output_limit: int | None = None,
66
+ ) -> dict[str, Any]:
67
+ options = self.explicit_options(config)
68
+ raw_num_ctx = config.get("num_ctx", "auto")
69
+ if not (
70
+ isinstance(raw_num_ctx, str)
71
+ and raw_num_ctx.strip().lower() in {"", "auto", "dynamic"}
72
+ ):
73
+ num_ctx = self.ports.positive_int(raw_num_ctx)
74
+ if num_ctx:
75
+ options["num_ctx"] = num_ctx
76
+
77
+ output_is_explicit = bool(config.get("output_tokens_explicit")) or (
78
+ "num_predict"
79
+ in self._marked_keys(config, "ollama_transient_options")
80
+ )
81
+ if output_is_explicit:
82
+ configured = self.ports.positive_int(
83
+ options.get("num_predict")
84
+ or config.get("max_output_tokens")
85
+ )
86
+ limit = self.ports.positive_int(output_limit)
87
+ if configured and limit:
88
+ options["num_predict"] = min(configured, limit)
89
+ elif configured:
90
+ options["num_predict"] = configured
91
+ elif limit:
92
+ options["num_predict"] = limit
93
+ else:
94
+ options.pop("num_predict", None)
95
+ return options
96
+
97
+ def apply(
98
+ self,
99
+ request: dict[str, Any],
100
+ provider: str,
101
+ model: str | None,
102
+ config: dict[str, Any],
103
+ source_request: Mapping[str, Any] | None = None,
104
+ *,
105
+ output_limit: int | None = None,
106
+ ) -> dict[str, Any]:
107
+ projected = dict(request)
108
+ think = self.ports.think_value(
109
+ provider, model, config, source_request or {}
110
+ )
111
+ if think is not None:
112
+ projected["think"] = think
113
+ keep_alive = self.keep_alive(config)
114
+ if keep_alive is not None:
115
+ projected["keep_alive"] = keep_alive
116
+ options = self.options(config, output_limit=output_limit)
117
+ if options:
118
+ projected["options"] = options
119
+ return projected
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class OllamaWireCompatibilityApi:
124
+ """Stable facade adapter backed by typed projection dependencies."""
125
+
126
+ think_value: Callable[
127
+ [str, str | None, dict[str, Any], Mapping[str, Any]], bool | str | None
128
+ ]
129
+ positive_int: Callable[[Any], int | None]
130
+
131
+ def apply(
132
+ self,
133
+ request: dict[str, Any],
134
+ provider: str,
135
+ model: str | None,
136
+ config: dict[str, Any],
137
+ source_request: Mapping[str, Any] | None = None,
138
+ *,
139
+ output_limit: int | None = None,
140
+ ) -> dict[str, Any]:
141
+ return OllamaWireProjection(
142
+ OllamaWireProjectionPorts(self.think_value, self.positive_int)
143
+ ).apply(
144
+ request,
145
+ provider,
146
+ model,
147
+ config,
148
+ source_request,
149
+ output_limit=output_limit,
150
+ )
151
+
152
+
153
+ __all__ = [
154
+ "OllamaWireCompatibilityApi",
155
+ "OllamaWireProjection",
156
+ "OllamaWireProjectionPorts",
157
+ ]
@@ -73,6 +73,7 @@ class OpenAIForwardServices:
73
73
  advisor: OpenAIForwardAdvisor
74
74
  streaming: OpenAIForwardStreaming
75
75
  response: OpenAIForwardResponse
76
+ hosted_tools: Any
76
77
  log: Callable[[str, str], Any]
77
78
 
78
79
 
@@ -100,10 +101,19 @@ def forward_openai_compatible_chat(
100
101
  original_body = body
101
102
  upstream_body = request.body_with_advisor_tool(body, pcfg) if request.advisor_provider_supported(provider) else body
102
103
  url = request.join_url(request.upstream_request_base(provider, pcfg), "/v1/chat/completions")
104
+ timeout = rate_limit.request_timeout_seconds(pcfg)
105
+ headers = request.provider_headers(provider, pcfg, handler.headers, "openai_chat")
103
106
  waited, rpm_used, rpm_limit = rate_limit.apply(provider, pcfg, model)
104
107
  compatibility_test = str(handler.headers.get(policy.compatibility_test_header) or "").strip().lower() in ("1", "true", "yes", "on")
105
108
  stream_enabled = bool(pcfg.get("stream_enabled", True))
106
109
  stream = policy.provider_requires_streaming(provider, pcfg) or (bool(body.get("stream", stream_enabled)) and stream_enabled)
110
+ collected_request = request.build_chat_request(provider, model, upstream_body, pcfg, stream=False)
111
+ collected_request, hosted_state = services.hosted_tools.prepare(
112
+ provider, pcfg, collected_request, headers, timeout
113
+ )
114
+ if hosted_state.enabled and stream:
115
+ stream = False
116
+ services.log("INFO", f"provider-hosted tools enabled for {provider}; collecting tool rounds internally")
107
117
  if stream and advisor.model_enabled(pcfg) and request.advisor_provider_supported(provider):
108
118
  stream = False
109
119
  services.log("INFO", f"advisor tool enabled for {provider}; collecting this turn so advisor tool calls can be resolved internally")
@@ -128,10 +138,8 @@ def forward_openai_compatible_chat(
128
138
  upstream_response = streaming.open_with_retry(
129
139
  url,
130
140
  req_body,
131
- request.provider_headers(
132
- provider, pcfg, handler.headers, "openai_chat"
133
- ),
134
- rate_limit.request_timeout_seconds(pcfg),
141
+ headers,
142
+ timeout,
135
143
  provider,
136
144
  pcfg,
137
145
  model,
@@ -151,7 +159,6 @@ def forward_openai_compatible_chat(
151
159
  )
152
160
  if stream_ok:
153
161
  response.mark_delivery_success(handler, "openai_stream_message_stop")
154
- response.write_activity("success", provider, model, tokens=req_tokens, bytes=req_bytes, stream=True)
155
162
  else:
156
163
  response.mark_delivery_failed(handler, "openai_stream_error")
157
164
  except RuntimeError as exc:
@@ -168,21 +175,36 @@ def forward_openai_compatible_chat(
168
175
  return
169
176
  return
170
177
 
171
- req_body = request.build_chat_request(provider, model, upstream_body, pcfg, stream=False)
178
+ req_body = collected_request
172
179
  try:
173
180
  data = streaming.post_json_with_retry(
174
181
  url,
175
182
  req_body,
176
- request.provider_headers(
177
- provider, pcfg, handler.headers, "openai_chat"
178
- ),
179
- rate_limit.request_timeout_seconds(pcfg),
183
+ headers,
184
+ timeout,
180
185
  provider,
181
186
  pcfg,
182
187
  model,
183
188
  None,
184
189
  retry_rate_limits=not compatibility_test,
185
190
  )
191
+ data = services.hosted_tools.resolve(
192
+ hosted_state,
193
+ req_body,
194
+ data,
195
+ lambda next_body: streaming.post_json_with_retry(
196
+ url,
197
+ next_body,
198
+ headers,
199
+ timeout,
200
+ provider,
201
+ pcfg,
202
+ model,
203
+ None,
204
+ retry_rate_limits=not compatibility_test,
205
+ ),
206
+ timeout,
207
+ )
186
208
  except RuntimeError as exc:
187
209
  response.write_json(handler, {"type": "error", "error": {"type": "upstream_error", "message": str(exc)}}, 500)
188
210
  return
@@ -41,6 +41,10 @@ class OpenAIResponsesRouting:
41
41
  dump_request: Callable[..., Any]
42
42
  normalize_provider_wire: Callable[..., dict[str, Any]]
43
43
  collect_message: Callable[..., dict[str, Any]]
44
+ # Both belong here rather than with the conversions: each one's behaviour is
45
+ # decided by the routed-vs-native split this group already owns.
46
+ apply_codex_compat_instructions: Callable[..., dict[str, Any]]
47
+ recover_preamble_only_turn: Callable[..., dict[str, Any]]
44
48
 
45
49
 
46
50
  @dataclass(frozen=True, slots=True)
@@ -82,6 +86,11 @@ def handle_openai_responses_request(
82
86
  routing = services.routing
83
87
  delivery = services.delivery
84
88
  output = services.output
89
+ # Codex cannot receive --append-system-prompt, so the routed compatibility
90
+ # instruction has to ride along in the request itself. Do this before the
91
+ # conversion so it reaches both the translated path and a native Responses
92
+ # provider; the native Codex backend is excluded inside the port.
93
+ body = routing.apply_codex_compat_instructions(cfg, provider, pcfg, body)
85
94
  anthropic_body = conversion.to_anthropic(body, conversion.current_alias(cfg))
86
95
  if routing.maybe_import_session(
87
96
  handler,
@@ -146,6 +155,9 @@ def handle_openai_responses_request(
146
155
  )
147
156
  try:
148
157
  message = routing.collect_message(handler, provider, pcfg, anthropic_body)
158
+ message = routing.recover_preamble_only_turn(
159
+ handler, provider, pcfg, anthropic_body, message
160
+ )
149
161
  output.write_response(handler, message, source_body=body, stream=stream)
150
162
  delivery.mark_success(handler, "responses_json")
151
163
  delivery.commit(anthropic_body, handler)
@@ -3,12 +3,41 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
+ import ntpath
6
7
  import os
7
8
  from pathlib import Path
8
9
  import subprocess
9
10
  from typing import Callable
10
11
 
11
12
 
13
+ def windows_executable_image_running(executable_name: str) -> bool:
14
+ """Return whether Windows currently has the executable image loaded.
15
+
16
+ npm replaces native package binaries in place. Windows rejects that copy
17
+ with EBUSY while any process is using the image, so the update must be
18
+ deferred instead of starting an install that cannot complete.
19
+ """
20
+
21
+ if os.name != "nt":
22
+ return False
23
+ image_name = ntpath.basename(executable_name)
24
+ if not image_name.casefold().endswith(".exe"):
25
+ image_name += ".exe"
26
+ try:
27
+ result = subprocess.run(
28
+ ["tasklist", "/FI", f"IMAGENAME eq {image_name}", "/FO", "CSV", "/NH"],
29
+ stdout=subprocess.PIPE,
30
+ stderr=subprocess.DEVNULL,
31
+ text=True,
32
+ timeout=5,
33
+ check=False,
34
+ )
35
+ except (OSError, subprocess.SubprocessError):
36
+ return False
37
+ expected = f'"{image_name}"'.casefold()
38
+ return any(line.strip().casefold().startswith(expected) for line in result.stdout.splitlines())
39
+
40
+
12
41
  @dataclass(frozen=True, slots=True)
13
42
  class NpmPackageLifecyclePorts:
14
43
  find_executable: Callable[[str], str | None]
@@ -98,6 +127,15 @@ class NpmPackageLifecycle:
98
127
  if current and not self._ports.version_newer(latest, current):
99
128
  self._print(f"{label} is up to date ({current}).")
100
129
  return executable
130
+ if executable_name.casefold() == "codex" and windows_executable_image_running(
131
+ executable_name
132
+ ):
133
+ self._print(
134
+ f"{label} update deferred: {executable_name}.exe is currently running and "
135
+ "Windows has locked the executable. Continuing with the current version; "
136
+ "the update will be retried on a later launch."
137
+ )
138
+ return executable
101
139
  self._print(f"{label} update available: {current or 'unknown'} -> {latest}; upgrading automatically.")
102
140
  prefix = self._ports.install_prefix()
103
141
  if prefix is not None:
@@ -214,4 +252,5 @@ __all__ = [
214
252
  "NpmPackageLifecyclePorts",
215
253
  "SelfUpdateLifecycle",
216
254
  "SelfUpdatePorts",
255
+ "windows_executable_image_running",
217
256
  ]