@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.
- package/ciel_runtime.py +2555 -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/config_repository.py +30 -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 +245 -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 +84 -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/speech_http_controller.py +335 -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/web_ui.py +248 -1
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/COLAB_SPEECH.md +32 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- package/package.json +4 -1
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
- package/scripts/colab/bootstrap_moss_tts.py +106 -0
- package/scripts/colab/bootstrap_qwen_asr.py +106 -0
- package/scripts/configure_speech_workers.py +37 -0
- 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
|
|
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
|
-
|
|
132
|
-
|
|
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 =
|
|
178
|
+
req_body = collected_request
|
|
172
179
|
try:
|
|
173
180
|
data = streaming.post_json_with_retry(
|
|
174
181
|
url,
|
|
175
182
|
req_body,
|
|
176
|
-
|
|
177
|
-
|
|
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
|
]
|