@oneciel-ai/ciel-runtime 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ciel_runtime.py +2553 -9635
- package/ciel_runtime_support/advisor_request_builder.py +8 -21
- package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
- package/ciel_runtime_support/architecture.py +68 -0
- package/ciel_runtime_support/architecture_budget.py +1 -1
- package/ciel_runtime_support/channel_connection_context.py +233 -0
- package/ciel_runtime_support/channel_delivery_context.py +332 -0
- package/ciel_runtime_support/channel_mcp_context.py +313 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
- package/ciel_runtime_support/channel_mcp_transport.py +5 -1
- package/ciel_runtime_support/channel_message_context.py +212 -0
- package/ciel_runtime_support/channel_message_repository.py +14 -3
- package/ciel_runtime_support/channel_pending_injection.py +9 -0
- package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
- package/ciel_runtime_support/channel_replay_policy.py +38 -0
- package/ciel_runtime_support/channel_runtime_environment.py +8 -0
- package/ciel_runtime_support/channel_session_context.py +236 -0
- package/ciel_runtime_support/channel_terminal_context.py +350 -0
- package/ciel_runtime_support/channel_wake_context.py +532 -0
- package/ciel_runtime_support/claude_environment.py +60 -0
- package/ciel_runtime_support/claude_launch_assembly.py +249 -0
- package/ciel_runtime_support/claude_router.py +62 -12
- package/ciel_runtime_support/cli_application_context.py +132 -0
- package/ciel_runtime_support/cli_assembly.py +50 -0
- package/ciel_runtime_support/codex_backend_context.py +363 -0
- package/ciel_runtime_support/codex_config.py +13 -1
- package/ciel_runtime_support/codex_launch_assembly.py +213 -0
- package/ciel_runtime_support/codex_launch_configuration.py +30 -1
- package/ciel_runtime_support/codex_mcp_integration.py +90 -8
- package/ciel_runtime_support/codex_model_catalog.py +4 -1
- package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
- package/ciel_runtime_support/codex_router.py +38 -8
- package/ciel_runtime_support/codex_turn_recovery.py +154 -0
- package/ciel_runtime_support/config_migrations.py +103 -0
- package/ciel_runtime_support/configuration_cli.py +38 -0
- package/ciel_runtime_support/context_compaction.py +9 -4
- package/ciel_runtime_support/credential_management.py +12 -0
- package/ciel_runtime_support/credentials.py +12 -0
- package/ciel_runtime_support/github_copilot_oauth.py +2 -2
- package/ciel_runtime_support/hosted_formula_tools.py +216 -0
- package/ciel_runtime_support/kimi_runtime_context.py +208 -0
- package/ciel_runtime_support/llm_preset_context.py +338 -0
- package/ciel_runtime_support/managed_mcp_config.py +8 -4
- package/ciel_runtime_support/mcp_configuration_context.py +291 -0
- package/ciel_runtime_support/mcp_http_proxy.py +14 -8
- package/ciel_runtime_support/mcp_probe_transport.py +47 -15
- package/ciel_runtime_support/mcp_transport.py +14 -1
- package/ciel_runtime_support/native_context_recovery.py +72 -0
- package/ciel_runtime_support/ollama_catalog_context.py +213 -0
- package/ciel_runtime_support/ollama_stream_collection.py +103 -0
- package/ciel_runtime_support/ollama_thinking.py +6 -1
- package/ciel_runtime_support/ollama_wire_projection.py +157 -0
- package/ciel_runtime_support/openai_forwarding.py +32 -10
- package/ciel_runtime_support/openai_responses_router.py +12 -0
- package/ciel_runtime_support/package_lifecycle.py +39 -0
- package/ciel_runtime_support/prelaunch_assembly.py +37 -0
- package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
- package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
- package/ciel_runtime_support/prompt_compaction.py +144 -0
- package/ciel_runtime_support/prompt_injection.py +45 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
- package/ciel_runtime_support/protocols/chat_projection.py +85 -5
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
- package/ciel_runtime_support/protocols/ollama_response.py +57 -5
- package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
- package/ciel_runtime_support/protocols/openai_responses.py +61 -15
- package/ciel_runtime_support/provider_adapters.py +26 -0
- package/ciel_runtime_support/provider_administration_context.py +207 -0
- package/ciel_runtime_support/provider_config_mutations.py +3 -0
- package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
- package/ciel_runtime_support/provider_model_context.py +107 -0
- package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
- package/ciel_runtime_support/provider_model_selection.py +10 -3
- package/ciel_runtime_support/provider_models.py +45 -2
- package/ciel_runtime_support/provider_option_cli.py +19 -0
- package/ciel_runtime_support/provider_policy.py +1 -1
- package/ciel_runtime_support/provider_readiness_context.py +189 -0
- package/ciel_runtime_support/provider_request_builder.py +64 -28
- package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
- package/ciel_runtime_support/provider_timeout_policy.py +54 -0
- package/ciel_runtime_support/provider_tool_policy.py +9 -1
- package/ciel_runtime_support/providers/__init__.py +6 -0
- package/ciel_runtime_support/providers/alibaba.py +634 -0
- package/ciel_runtime_support/providers/catalog.py +24 -16
- package/ciel_runtime_support/providers/deepseek.py +73 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
- package/ciel_runtime_support/providers/kimi.py +69 -9
- package/ciel_runtime_support/providers/ollama.py +8 -0
- package/ciel_runtime_support/providers/ollama_context.py +21 -2
- package/ciel_runtime_support/providers/vllm.py +7 -1
- package/ciel_runtime_support/response_collection.py +68 -18
- package/ciel_runtime_support/response_collection_context.py +391 -0
- package/ciel_runtime_support/response_stream_context.py +555 -0
- package/ciel_runtime_support/responses_input_compatibility.py +121 -0
- package/ciel_runtime_support/responses_usage_observer.py +83 -0
- package/ciel_runtime_support/router_client_lifecycle.py +1 -0
- package/ciel_runtime_support/router_http.py +239 -3
- package/ciel_runtime_support/router_observability_context.py +251 -0
- package/ciel_runtime_support/router_process_context.py +200 -0
- package/ciel_runtime_support/router_process_lifecycle.py +2 -0
- package/ciel_runtime_support/router_request_assembly.py +399 -0
- package/ciel_runtime_support/router_request_context.py +215 -0
- package/ciel_runtime_support/router_server_context.py +82 -0
- package/ciel_runtime_support/runaway_output_guard.py +488 -0
- package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
- package/ciel_runtime_support/runtime_asset_context.py +297 -0
- package/ciel_runtime_support/runtime_constants.py +16 -1
- package/ciel_runtime_support/runtime_launch.py +9 -5
- package/ciel_runtime_support/runtime_launch_context.py +130 -0
- package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
- package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
- package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
- package/ciel_runtime_support/runtime_paths.py +60 -40
- package/ciel_runtime_support/runtime_primitives.py +78 -0
- package/ciel_runtime_support/sse_stream_collection.py +236 -0
- package/ciel_runtime_support/statusline_script.py +57 -8
- package/ciel_runtime_support/streaming_anthropic.py +361 -24
- package/ciel_runtime_support/tool_schema.py +40 -2
- package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
- package/ciel_runtime_support/upstream_dump.py +68 -0
- package/ciel_runtime_support/upstream_retry_context.py +259 -0
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@ from typing import Any, Callable
|
|
|
13
13
|
import urllib.error
|
|
14
14
|
import urllib.request
|
|
15
15
|
|
|
16
|
+
from .mcp_transport import negotiated_protocol_version
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
@dataclass(frozen=True, slots=True)
|
|
18
20
|
class McpHttpProxyCodec:
|
|
@@ -101,6 +103,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
101
103
|
endpoint = str(server.get("url") or server.get("endpoint") or "").strip()
|
|
102
104
|
headers = mcp_server_runtime_headers(server)
|
|
103
105
|
protocol_version = str(server.get("mcp_protocol_version") or server.get("protocolVersion") or server.get("protocol_version") or MCP_STREAMABLE_HTTP_PROTOCOL_VERSION)
|
|
106
|
+
active_protocol_version = protocol_version
|
|
104
107
|
timeout = max(5.0, min(120.0, float(server.get("mcp_timeout_seconds") or server.get("timeout") or 20.0)))
|
|
105
108
|
requires_session = parse_bool(server.get("streamable_requires_session", server.get("require_session", server.get("mcp_session_required", True))), True)
|
|
106
109
|
notification_stream_enabled = not _mcp_server_disable_proxy_notification_stream(server)
|
|
@@ -236,19 +239,22 @@ def run_mcp_streamable_http_proxy(
|
|
|
236
239
|
Publishes the initialize result (for the stdin initialize reply) and the
|
|
237
240
|
new session id under session_cond, waking any waiter.
|
|
238
241
|
"""
|
|
239
|
-
nonlocal session_id, initialize_result
|
|
242
|
+
nonlocal session_id, initialize_result, active_protocol_version
|
|
240
243
|
init_payload = initialize_payload
|
|
241
244
|
if not init_payload:
|
|
242
245
|
return None
|
|
246
|
+
params = init_payload.get("params") if isinstance(init_payload.get("params"), dict) else {}
|
|
247
|
+
requested_protocol = str(params.get("protocolVersion") or protocol_version)
|
|
243
248
|
try:
|
|
244
249
|
result, returned_session = _mcp_proxy_streamable_http_request(
|
|
245
|
-
endpoint, headers, init_payload, timeout,
|
|
250
|
+
endpoint, headers, init_payload, timeout, requested_protocol, None,
|
|
246
251
|
)
|
|
247
252
|
except Exception as exc:
|
|
248
253
|
router_log("WARN", f"mcp_http_proxy_session_init_failed server={server_name} error={type(exc).__name__}: {exc}")
|
|
249
254
|
return None
|
|
250
255
|
if isinstance(result, dict):
|
|
251
256
|
_mcp_proxy_observe_json_message(server_name, result)
|
|
257
|
+
active_protocol_version = negotiated_protocol_version(result, requested_protocol)
|
|
252
258
|
with session_cond:
|
|
253
259
|
session_id = returned_session or session_id
|
|
254
260
|
if isinstance(result, dict):
|
|
@@ -258,7 +264,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
258
264
|
if new_session and initialized_payload:
|
|
259
265
|
try:
|
|
260
266
|
_mcp_proxy_streamable_http_request(
|
|
261
|
-
endpoint, headers, initialized_payload, timeout,
|
|
267
|
+
endpoint, headers, initialized_payload, timeout, active_protocol_version, new_session,
|
|
262
268
|
)
|
|
263
269
|
except Exception as exc:
|
|
264
270
|
router_log(
|
|
@@ -347,7 +353,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
347
353
|
reopen_after_close = False
|
|
348
354
|
try:
|
|
349
355
|
request_headers = _mcp_streamable_headers(
|
|
350
|
-
headers,
|
|
356
|
+
headers, active_protocol_version, worker_session, accept="text/event-stream",
|
|
351
357
|
)
|
|
352
358
|
if last_event_id:
|
|
353
359
|
request_headers["Last-Event-ID"] = last_event_id
|
|
@@ -463,7 +469,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
463
469
|
active = session_id
|
|
464
470
|
if active:
|
|
465
471
|
try:
|
|
466
|
-
_mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout,
|
|
472
|
+
_mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, active_protocol_version, active)
|
|
467
473
|
router_log("INFO", f"mcp_http_proxy_initialized_forwarded server={server_name} session={active}")
|
|
468
474
|
except urllib.error.HTTPError as exc:
|
|
469
475
|
body_text = _http_error_body_text(exc)
|
|
@@ -519,7 +525,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
519
525
|
if not active_session:
|
|
520
526
|
raise RuntimeError("Streamable HTTP MCP session is not initialized")
|
|
521
527
|
result, _returned = _mcp_proxy_streamable_http_request(
|
|
522
|
-
endpoint, headers, payload, timeout,
|
|
528
|
+
endpoint, headers, payload, timeout, active_protocol_version, active_session,
|
|
523
529
|
)
|
|
524
530
|
tool_name = _mcp_proxy_tool_call_name(payload)
|
|
525
531
|
if isinstance(result, dict):
|
|
@@ -549,7 +555,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
549
555
|
if active_session:
|
|
550
556
|
try:
|
|
551
557
|
result, _r = _mcp_proxy_streamable_http_request(
|
|
552
|
-
endpoint, headers, payload, timeout,
|
|
558
|
+
endpoint, headers, payload, timeout, active_protocol_version, active_session,
|
|
553
559
|
)
|
|
554
560
|
tool_name = _mcp_proxy_tool_call_name(payload)
|
|
555
561
|
if isinstance(result, dict):
|
|
@@ -572,7 +578,7 @@ def run_mcp_streamable_http_proxy(
|
|
|
572
578
|
if _mcp_proxy_tool_is_notification_wait(_mcp_proxy_tool_call_name(payload)):
|
|
573
579
|
_mcp_proxy_write_json_response(wait_for_proxy_notifications(payload))
|
|
574
580
|
continue
|
|
575
|
-
result, _r = _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout,
|
|
581
|
+
result, _r = _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, active_protocol_version, current_session_id())
|
|
576
582
|
tool_name = _mcp_proxy_tool_call_name(payload)
|
|
577
583
|
if isinstance(result, dict):
|
|
578
584
|
result = _mcp_proxy_compact_tool_result_response(server_name, tool_name, result)
|
|
@@ -248,23 +248,34 @@ def probe_streamable_http_mcp_for_channel_capability_detailed(
|
|
|
248
248
|
initialized_failed = False
|
|
249
249
|
session_id: str | None = None
|
|
250
250
|
try:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
251
|
+
# A channel-capable server can occasionally lose a freshly-created
|
|
252
|
+
# session between ``initialize`` and ``notifications/initialized``
|
|
253
|
+
# (for example when a load balancer routes the two requests to
|
|
254
|
+
# different workers). Treating that one handshake failure as a
|
|
255
|
+
# durable negative capability result leaves the cached record false
|
|
256
|
+
# on every later launch, so Ciel never owns the notification stream.
|
|
257
|
+
# Retry the complete handshake once, but only after the server has
|
|
258
|
+
# already advertised the channel capability.
|
|
259
|
+
for handshake_attempt in range(2):
|
|
260
|
+
response, session_id = http.streamable_post_json(
|
|
261
|
+
url,
|
|
262
|
+
headers,
|
|
263
|
+
codec.initialize_dict(protocol_version),
|
|
264
|
+
max(1.0, min(120.0, effective_timeout)),
|
|
265
|
+
protocol_version,
|
|
266
|
+
)
|
|
267
|
+
preview_source = json.dumps(response, ensure_ascii=False) if isinstance(response, (dict, list)) else str(response or "")
|
|
268
|
+
bytes_seen = len(preview_source.encode("utf-8", errors="replace"))
|
|
269
|
+
response_received = bool(
|
|
270
|
+
isinstance(response, dict)
|
|
271
|
+
and response.get("id") == 1
|
|
272
|
+
and "result" in response
|
|
273
|
+
)
|
|
274
|
+
capable = response_received and codec.capability_present(response)
|
|
263
275
|
if not capable:
|
|
264
276
|
stdout_preview = codec.decode_preview(preview_source.encode("utf-8"), policy.stdout_preview_bytes)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if response_received:
|
|
277
|
+
if not response_received:
|
|
278
|
+
break
|
|
268
279
|
try:
|
|
269
280
|
http.streamable_post_json(
|
|
270
281
|
url,
|
|
@@ -274,10 +285,31 @@ def probe_streamable_http_mcp_for_channel_capability_detailed(
|
|
|
274
285
|
protocol_version,
|
|
275
286
|
session_id,
|
|
276
287
|
)
|
|
288
|
+
initialized_failed = False
|
|
289
|
+
stderr_preview = ""
|
|
290
|
+
break
|
|
277
291
|
except Exception as exc:
|
|
278
292
|
initialized_failed = True
|
|
279
293
|
capable = False
|
|
280
294
|
stderr_preview = f"{type(exc).__name__}: {exc}"[:policy.stderr_preview_chars]
|
|
295
|
+
if handshake_attempt != 0 or not codec.capability_present(response):
|
|
296
|
+
break
|
|
297
|
+
services.log(
|
|
298
|
+
"INFO",
|
|
299
|
+
"channel_probe_streamable_http_handshake_retry "
|
|
300
|
+
f"server={server_name} error={type(exc).__name__}: {exc}",
|
|
301
|
+
)
|
|
302
|
+
if session_id:
|
|
303
|
+
http.delete_streamable_session(
|
|
304
|
+
f"probe-{server_name}",
|
|
305
|
+
url,
|
|
306
|
+
headers,
|
|
307
|
+
protocol_version,
|
|
308
|
+
session_id,
|
|
309
|
+
"channel_probe_retry_cleanup",
|
|
310
|
+
timeout=min(10.0, max(1.0, effective_timeout)),
|
|
311
|
+
)
|
|
312
|
+
session_id = None
|
|
281
313
|
reason = "streamable_http_initialized_post_failed" if initialized_failed else (
|
|
282
314
|
"capable" if capable else ("no_experimental_claude_channel" if response_received else "no_initialize_response")
|
|
283
315
|
)
|
|
@@ -8,7 +8,7 @@ import urllib.request
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
MCP_STREAMABLE_HTTP_PROTOCOL_VERSION = "2025-
|
|
11
|
+
MCP_STREAMABLE_HTTP_PROTOCOL_VERSION = "2025-11-25"
|
|
12
12
|
MCP_LEGACY_SSE_PROTOCOL_VERSION = "2024-11-05"
|
|
13
13
|
CODEX_MCP_SPLIT_PROXY_PREFIX = "/ca/codex-mcp/"
|
|
14
14
|
|
|
@@ -114,6 +114,18 @@ def streamable_post_json(
|
|
|
114
114
|
return result, str(returned_session).strip() if returned_session else None
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
def negotiated_protocol_version(result: Any, requested: str) -> str:
|
|
118
|
+
"""Return the protocol revision selected by a legacy initialize exchange."""
|
|
119
|
+
|
|
120
|
+
if isinstance(result, dict):
|
|
121
|
+
payload = result.get("result")
|
|
122
|
+
if isinstance(payload, dict):
|
|
123
|
+
selected = str(payload.get("protocolVersion") or "").strip()
|
|
124
|
+
if selected:
|
|
125
|
+
return selected
|
|
126
|
+
return str(requested or MCP_STREAMABLE_HTTP_PROTOCOL_VERSION).strip()
|
|
127
|
+
|
|
128
|
+
|
|
117
129
|
def split_proxy_server_name(path: str, prefix: str = CODEX_MCP_SPLIT_PROXY_PREFIX) -> str | None:
|
|
118
130
|
if not path.startswith(prefix):
|
|
119
131
|
return None
|
|
@@ -137,6 +149,7 @@ __all__ = [
|
|
|
137
149
|
"MCP_LEGACY_SSE_PROTOCOL_VERSION",
|
|
138
150
|
"MCP_STREAMABLE_HTTP_PROTOCOL_VERSION",
|
|
139
151
|
"post_json_with_response_headers",
|
|
152
|
+
"negotiated_protocol_version",
|
|
140
153
|
"read_sse_json_response",
|
|
141
154
|
"split_proxy_server_name",
|
|
142
155
|
"sse_post_json",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Recover Anthropic-compatible requests rejected by an upstream context limit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ContextOverflow:
|
|
12
|
+
context_limit: int
|
|
13
|
+
message_tokens: int
|
|
14
|
+
completion_tokens: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_OVERFLOW_PATTERN = re.compile(
|
|
18
|
+
r"maximum context length is\s*(?P<limit>[\d,]+)\s*tokens?.*?"
|
|
19
|
+
r"requested\s*(?P<requested>[\d,]+)\s*tokens?\s*\(\s*"
|
|
20
|
+
r"(?P<messages>[\d,]+)\s*in the messages?,\s*"
|
|
21
|
+
r"(?P<completion>[\d,]+)\s*in the completion",
|
|
22
|
+
re.IGNORECASE | re.DOTALL,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _integer(value: str) -> int:
|
|
27
|
+
try:
|
|
28
|
+
return int(value.replace(",", ""))
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_context_overflow(raw: str | bytes | None) -> ContextOverflow | None:
|
|
34
|
+
text = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw or "")
|
|
35
|
+
match = _OVERFLOW_PATTERN.search(text)
|
|
36
|
+
if not match:
|
|
37
|
+
return None
|
|
38
|
+
limit = _integer(match.group("limit"))
|
|
39
|
+
messages = _integer(match.group("messages"))
|
|
40
|
+
completion = _integer(match.group("completion"))
|
|
41
|
+
if limit <= 0 or messages <= 0 or completion <= 0:
|
|
42
|
+
return None
|
|
43
|
+
return ContextOverflow(limit, messages, completion)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def recover_output_budget(
|
|
47
|
+
body: dict[str, Any],
|
|
48
|
+
raw: str | bytes | None,
|
|
49
|
+
*,
|
|
50
|
+
reserve_tokens: int = 0,
|
|
51
|
+
minimum_output_tokens: int = 256,
|
|
52
|
+
) -> dict[str, Any] | None:
|
|
53
|
+
"""Reduce only the output reservation when the prompt itself still fits."""
|
|
54
|
+
|
|
55
|
+
overflow = parse_context_overflow(raw)
|
|
56
|
+
if overflow is None:
|
|
57
|
+
return None
|
|
58
|
+
reserve = max(0, int(reserve_tokens or 0))
|
|
59
|
+
minimum = max(1, int(minimum_output_tokens or 1))
|
|
60
|
+
available = overflow.context_limit - overflow.message_tokens - reserve
|
|
61
|
+
if available < minimum:
|
|
62
|
+
return None
|
|
63
|
+
requested = int(body.get("max_tokens") or overflow.completion_tokens)
|
|
64
|
+
adjusted = min(requested, overflow.completion_tokens, available)
|
|
65
|
+
if adjusted >= requested:
|
|
66
|
+
return None
|
|
67
|
+
recovered = dict(body)
|
|
68
|
+
recovered["max_tokens"] = adjusted
|
|
69
|
+
return recovered
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["ContextOverflow", "parse_context_overflow", "recover_output_budget"]
|
|
@@ -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
|
|