@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,78 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Mapping
6
+
7
+
8
+ def source_fingerprint(path: Path) -> str:
9
+ try:
10
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
11
+ except Exception:
12
+ try:
13
+ stat = path.stat()
14
+ return f"{int(stat.st_mtime_ns)}-{int(stat.st_size)}"
15
+ except Exception:
16
+ return "unknown"
17
+
18
+
19
+ def positive_environment_int(environment: Mapping[str, str], name: str, default: int) -> int:
20
+ raw = str(environment.get(name) or "").strip()
21
+ if raw:
22
+ try:
23
+ value = int(raw)
24
+ if value > 0:
25
+ return value
26
+ except ValueError:
27
+ pass
28
+ return default
29
+
30
+
31
+ def model_preset(
32
+ model_id: str,
33
+ presets: Mapping[str, dict[str, Any]],
34
+ lookup_ids: Callable[[str], tuple[str, ...] | list[str]],
35
+ ) -> dict[str, Any]:
36
+ for candidate in lookup_ids(model_id):
37
+ if candidate in presets:
38
+ return presets[candidate]
39
+ candidate_base = candidate.split(":", 1)[0]
40
+ for key, value in presets.items():
41
+ if candidate.startswith(key) or (":" not in candidate and key.startswith(candidate_base)):
42
+ return value
43
+ return {}
44
+
45
+
46
+ def join_url(base: str, path: str) -> str:
47
+ base = base.rstrip("/")
48
+ if base.endswith("/v1") and path.startswith("/v1/"):
49
+ return base + path[3:]
50
+ return base + path
51
+
52
+
53
+ def url_is_up(url: str, request_json: Callable[..., Any]) -> bool:
54
+ try:
55
+ request_json(url, timeout=1.5)
56
+ return True
57
+ except Exception:
58
+ return False
59
+
60
+
61
+ def colorize_status_text(
62
+ text: str,
63
+ *,
64
+ enabled: bool,
65
+ palette: tuple[int, ...],
66
+ monotonic: Callable[[], float],
67
+ ) -> str:
68
+ if not enabled:
69
+ return text
70
+ parts: list[str] = []
71
+ phase = int(monotonic() * 8)
72
+ for index, char in enumerate(text):
73
+ if char.isspace():
74
+ parts.append(char)
75
+ continue
76
+ color = palette[(phase + index) % len(palette)]
77
+ parts.append(f"\033[1;38;5;{color}m{char}\033[0m")
78
+ return "".join(parts)
@@ -0,0 +1,335 @@
1
+ """Speech configuration and OpenAI-compatible ASR/TTS proxy endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import secrets
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from http.server import BaseHTTPRequestHandler
13
+ from typing import Any, Callable
14
+
15
+
16
+ SpeechConfig = dict[str, Any]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class SpeechHttpPorts:
21
+ load_config: Callable[[], dict[str, Any]]
22
+ save_config: Callable[[dict[str, Any]], None]
23
+ write_json: Callable[..., None]
24
+ log: Callable[[str, str], None]
25
+ urlopen: Callable[..., Any] = urllib.request.urlopen
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class SpeechHttpController:
30
+ ports: SpeechHttpPorts
31
+
32
+ def get(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
33
+ if path == "/ca/speech/config":
34
+ self.ports.write_json(handler, self.public_config())
35
+ return True
36
+ if path == "/ca/speech/health":
37
+ self.ports.write_json(handler, self.health_payload())
38
+ return True
39
+ if path == "/ca/web/chat/api":
40
+ self.ports.write_json(handler, self.discovery_payload())
41
+ return True
42
+ if path == "/v1/audio/voices":
43
+ return self._proxy_get(handler, "tts", "voices_endpoint")
44
+ return False
45
+
46
+ def post(
47
+ self,
48
+ handler: BaseHTTPRequestHandler,
49
+ path: str,
50
+ raw: bytes,
51
+ content_type: str,
52
+ ) -> bool:
53
+ if path == "/ca/speech/config":
54
+ return self._save_public_config(handler, raw)
55
+ if path in {"/v1/audio/transcriptions", "/v1/audio/translations"}:
56
+ return self._proxy_asr(handler, raw, content_type)
57
+ if path in {"/v1/audio/speech", "/v1/audio/speech/batch"}:
58
+ return self._proxy_tts(handler, raw, content_type, batch=path.endswith("/batch"))
59
+ if path == "/v1/audio/voices":
60
+ return self._proxy_raw(handler, "tts", "voices_endpoint", raw, content_type)
61
+ return False
62
+
63
+ def public_config(self) -> dict[str, Any]:
64
+ speech = self._speech_config()
65
+ public: dict[str, Any] = {"ok": True}
66
+ for name in ("asr", "tts"):
67
+ source = speech.get(name) if isinstance(speech.get(name), dict) else {}
68
+ item = {key: value for key, value in source.items() if key != "api_key"}
69
+ item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
70
+ public[name] = item
71
+ tailscale = speech.get("tailscale")
72
+ public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
73
+ public["endpoints"] = self.discovery_payload()["endpoints"]
74
+ return public
75
+
76
+ def discovery_payload(self) -> dict[str, Any]:
77
+ return {
78
+ "ok": True,
79
+ "web_chat": "/ca/web/chat",
80
+ "endpoints": {
81
+ "chat_health": "GET /ca/channel/health",
82
+ "chat_messages": "GET|POST /ca/channel/messages",
83
+ "chat_wait": "GET /ca/channel/wait",
84
+ "chat_stream": "GET /ca/channel/stream",
85
+ "chat_files": "POST /ca/channel/files",
86
+ "speech_config": "GET|POST /ca/speech/config",
87
+ "speech_health": "GET /ca/speech/health",
88
+ "asr": "POST /v1/audio/transcriptions",
89
+ "asr_translate": "POST /v1/audio/translations",
90
+ "tts": "POST /v1/audio/speech",
91
+ "tts_batch": "POST /v1/audio/speech/batch",
92
+ "tts_voices": "GET|POST /v1/audio/voices",
93
+ "models": "GET /v1/models",
94
+ "responses": "POST /v1/responses",
95
+ "messages": "POST /v1/messages",
96
+ },
97
+ }
98
+
99
+ def health_payload(self) -> dict[str, Any]:
100
+ services = {name: self._probe(name) for name in ("asr", "tts")}
101
+ return {"ok": all(not item["enabled"] or item["reachable"] for item in services.values()), "services": services}
102
+
103
+ def _speech_config(self) -> SpeechConfig:
104
+ speech = self.ports.load_config().get("speech")
105
+ return speech if isinstance(speech, dict) else {}
106
+
107
+ def _service_config(self, name: str) -> SpeechConfig:
108
+ service = self._speech_config().get(name)
109
+ return service if isinstance(service, dict) else {}
110
+
111
+ def _save_public_config(self, handler: BaseHTTPRequestHandler, raw: bytes) -> bool:
112
+ try:
113
+ update = json.loads(raw.decode("utf-8") if raw else "{}")
114
+ if not isinstance(update, dict):
115
+ raise ValueError("configuration body must be a JSON object")
116
+ config = self.ports.load_config()
117
+ speech = config.setdefault("speech", {})
118
+ if not isinstance(speech, dict):
119
+ speech = {}
120
+ config["speech"] = speech
121
+ for name in ("asr", "tts"):
122
+ incoming = update.get(name)
123
+ if not isinstance(incoming, dict):
124
+ continue
125
+ current = speech.setdefault(name, {})
126
+ if not isinstance(current, dict):
127
+ current = {}
128
+ speech[name] = current
129
+ for key, value in incoming.items():
130
+ if key in {"api_key_set", "clear_api_key"}:
131
+ continue
132
+ if key == "api_key" and not str(value or "").strip():
133
+ continue
134
+ current[key] = self._validated_value(name, key, value)
135
+ if incoming.get("clear_api_key") is True:
136
+ current["api_key"] = ""
137
+ tailscale = update.get("tailscale")
138
+ if isinstance(tailscale, dict):
139
+ current_tailscale = speech.setdefault("tailscale", {})
140
+ if not isinstance(current_tailscale, dict):
141
+ current_tailscale = {}
142
+ speech["tailscale"] = current_tailscale
143
+ for key in ("enabled", "asr_hostname", "tts_hostname"):
144
+ if key in tailscale:
145
+ current_tailscale[key] = tailscale[key]
146
+ self.ports.save_config(config)
147
+ self.ports.write_json(handler, self.public_config())
148
+ except (UnicodeError, ValueError, TypeError) as exc:
149
+ self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
150
+ return True
151
+
152
+ @staticmethod
153
+ def _validated_value(service: str, key: str, value: Any) -> Any:
154
+ allowed = {
155
+ "asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
156
+ "tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
157
+ }
158
+ if key not in allowed[service]:
159
+ raise ValueError(f"unsupported {service} setting: {key}")
160
+ if key in {"enabled", "auto_speak"}:
161
+ return bool(value)
162
+ if key == "timeout_seconds":
163
+ return max(1, min(3600, int(value)))
164
+ if key == "speed":
165
+ return max(0.25, min(4.0, float(value)))
166
+ text = str(value or "").strip()
167
+ if key == "base_url":
168
+ parsed = urllib.parse.urlparse(text)
169
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
170
+ raise ValueError(f"invalid {service} base_url")
171
+ return text.rstrip("/")
172
+ if key in {"endpoint", "voices_endpoint"} and not text.startswith("/"):
173
+ raise ValueError(f"{service} {key} must begin with /")
174
+ return text
175
+
176
+ def _probe(self, name: str) -> dict[str, Any]:
177
+ config = self._service_config(name)
178
+ enabled = bool(config.get("enabled"))
179
+ result: dict[str, Any] = {
180
+ "enabled": enabled,
181
+ "configured": bool(str(config.get("base_url") or "").strip()),
182
+ "reachable": False,
183
+ }
184
+ if not enabled:
185
+ return result
186
+ try:
187
+ request = urllib.request.Request(self._url(config, "/health"), headers=self._headers(config, "application/json"))
188
+ with self.ports.urlopen(request, timeout=min(10.0, self._timeout(config))) as response:
189
+ result["status"] = int(getattr(response, "status", 200))
190
+ result["reachable"] = result["status"] < 500
191
+ except Exception as exc:
192
+ result["error"] = f"{type(exc).__name__}: {exc}"
193
+ return result
194
+
195
+ def _proxy_get(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str) -> bool:
196
+ config = self._service_config(service)
197
+ if not self._require_enabled(handler, service, config):
198
+ return True
199
+ request = urllib.request.Request(self._url(config, str(config.get(endpoint_key) or "")), headers=self._headers(config, "application/json"))
200
+ return self._open_and_write(handler, service, config, request)
201
+
202
+ def _proxy_asr(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str) -> bool:
203
+ config = self._service_config("asr")
204
+ if not self._require_enabled(handler, "asr", config):
205
+ return True
206
+ if "application/json" in content_type.lower():
207
+ try:
208
+ body = json.loads(raw.decode("utf-8"))
209
+ if not isinstance(body, dict):
210
+ raise ValueError("request must be a JSON object")
211
+ audio = base64.b64decode(str(body.get("audio_base64") or ""), validate=True)
212
+ if not audio:
213
+ raise ValueError("audio_base64 is required")
214
+ fields = {
215
+ "model": str(body.get("model") or config.get("model") or ""),
216
+ "language": str(body.get("language") or config.get("language") or ""),
217
+ "response_format": str(body.get("response_format") or "json"),
218
+ }
219
+ if fields["language"].lower() == "auto":
220
+ fields.pop("language")
221
+ raw, content_type = self._multipart(fields, "file", str(body.get("filename") or "recording.wav"), str(body.get("content_type") or "audio/wav"), audio)
222
+ except (ValueError, TypeError, UnicodeError) as exc:
223
+ self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
224
+ return True
225
+ return self._proxy_raw(handler, "asr", "endpoint", raw, content_type)
226
+
227
+ def _proxy_tts(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str, *, batch: bool) -> bool:
228
+ config = self._service_config("tts")
229
+ if not self._require_enabled(handler, "tts", config):
230
+ return True
231
+ if "application/json" in content_type.lower():
232
+ try:
233
+ body = json.loads(raw.decode("utf-8") if raw else "{}")
234
+ if not isinstance(body, dict):
235
+ raise ValueError("request must be a JSON object")
236
+ if not batch:
237
+ body.setdefault("model", str(config.get("model") or ""))
238
+ body.setdefault("voice", str(config.get("voice") or "default"))
239
+ body.setdefault("language", str(config.get("language") or "Auto"))
240
+ body.setdefault("response_format", str(config.get("response_format") or "wav"))
241
+ body.setdefault("speed", float(config.get("speed") or 1.0))
242
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
243
+ except (ValueError, TypeError, UnicodeError) as exc:
244
+ self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
245
+ return True
246
+ endpoint = str(config.get("endpoint") or "/v1/audio/speech") + ("/batch" if batch else "")
247
+ return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type)
248
+
249
+ def _proxy_raw(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str, raw: bytes, content_type: str) -> bool:
250
+ config = self._service_config(service)
251
+ if not self._require_enabled(handler, service, config):
252
+ return True
253
+ return self._proxy_bytes(handler, service, config, str(config.get(endpoint_key) or ""), raw, content_type)
254
+
255
+ def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str) -> bool:
256
+ request = urllib.request.Request(
257
+ self._url(config, endpoint),
258
+ data=raw,
259
+ headers=self._headers(config, content_type or "application/octet-stream"),
260
+ method="POST",
261
+ )
262
+ return self._open_and_write(handler, service, config, request)
263
+
264
+ def _open_and_write(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
265
+ try:
266
+ with self.ports.urlopen(request, timeout=self._timeout(config)) as response:
267
+ self._write_bytes(handler, response.read(), int(getattr(response, "status", 200)), str(response.headers.get("content-type") or "application/octet-stream"))
268
+ except urllib.error.HTTPError as exc:
269
+ self._write_bytes(handler, exc.read(), int(exc.code), str(exc.headers.get("content-type") or "application/json"))
270
+ except Exception as exc:
271
+ self.ports.log("ERROR", f"speech_proxy_failed service={service} error={type(exc).__name__}: {exc}")
272
+ self.ports.write_json(handler, {"error": {"type": "upstream_error", "message": f"{service.upper()} upstream unavailable: {exc}"}}, 502)
273
+ return True
274
+
275
+ @staticmethod
276
+ def _require_enabled(handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig) -> bool:
277
+ if bool(config.get("enabled")) and str(config.get("base_url") or "").strip():
278
+ return True
279
+ body = json.dumps({"error": {"type": "service_disabled", "message": f"{service.upper()} is not configured or enabled"}}).encode()
280
+ handler.send_response(503)
281
+ handler.send_header("content-type", "application/json")
282
+ handler.send_header("content-length", str(len(body)))
283
+ handler.end_headers()
284
+ handler.wfile.write(body)
285
+ return False
286
+
287
+ @staticmethod
288
+ def _write_bytes(handler: BaseHTTPRequestHandler, data: bytes, status: int, content_type: str) -> None:
289
+ handler.send_response(status)
290
+ handler.send_header("content-type", content_type)
291
+ handler.send_header("content-length", str(len(data)))
292
+ handler.send_header("cache-control", "no-store")
293
+ handler.end_headers()
294
+ handler.wfile.write(data)
295
+
296
+ @staticmethod
297
+ def _headers(config: SpeechConfig, content_type: str) -> dict[str, str]:
298
+ headers = {"accept": "*/*", "content-type": content_type}
299
+ api_key = str(config.get("api_key") or "").strip()
300
+ if api_key:
301
+ headers["authorization"] = f"Bearer {api_key}"
302
+ return headers
303
+
304
+ @staticmethod
305
+ def _url(config: SpeechConfig, endpoint: str) -> str:
306
+ return str(config.get("base_url") or "").rstrip("/") + "/" + endpoint.lstrip("/")
307
+
308
+ @staticmethod
309
+ def _timeout(config: SpeechConfig) -> float:
310
+ try:
311
+ return max(1.0, min(3600.0, float(config.get("timeout_seconds") or 300)))
312
+ except (TypeError, ValueError):
313
+ return 300.0
314
+
315
+ @staticmethod
316
+ def _multipart(fields: dict[str, str], file_field: str, filename: str, mime: str, data: bytes) -> tuple[bytes, str]:
317
+ boundary = "ciel-" + secrets.token_hex(16)
318
+ chunks: list[bytes] = []
319
+ for name, value in fields.items():
320
+ chunks.extend([
321
+ f"--{boundary}\r\n".encode(),
322
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
323
+ str(value).encode("utf-8"), b"\r\n",
324
+ ])
325
+ safe_filename = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
326
+ chunks.extend([
327
+ f"--{boundary}\r\n".encode(),
328
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{safe_filename}"\r\n'.encode(),
329
+ f"Content-Type: {mime}\r\n\r\n".encode(), data, b"\r\n",
330
+ f"--{boundary}--\r\n".encode(),
331
+ ])
332
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
333
+
334
+
335
+ __all__ = ["SpeechHttpController", "SpeechHttpPorts"]
@@ -0,0 +1,236 @@
1
+ """Collect SSE chat responses into one message, cutting loops short.
2
+
3
+ The Ollama collection path reads NDJSON; the other two protocols the
4
+ Codex-facing collector speaks are SSE. DeepSeek publishes both formats -- an
5
+ OpenAI-compatible endpoint at ``https://api.deepseek.com`` and an
6
+ Anthropic-compatible one at ``https://api.deepseek.com/anthropic`` -- and
7
+ ciel-runtime uses the Anthropic one, so a repetition loop on deepseek.com
8
+ arrives through :func:`collect_anthropic_message_stream`. Switching endpoints
9
+ would not have helped: both collectors used one blocking POST, which is what
10
+ let a loop run to completion before anything could look at it.
11
+
12
+ Each collector assembles exactly the payload the matching decoder already
13
+ expects, so nothing downstream changes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass, field
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 SseStreamCollection:
31
+ response: dict[str, Any]
32
+ verdict: RunawayVerdict | None = None
33
+ chunks: int = 0
34
+
35
+
36
+ def iter_sse_payloads(lines: Iterable[Any]) -> Iterable[dict[str, Any]]:
37
+ """Yield decoded ``data:`` payloads, ignoring framing and keepalives."""
38
+
39
+ for raw in lines:
40
+ line = (
41
+ raw.decode("utf-8", errors="ignore")
42
+ if isinstance(raw, (bytes, bytearray))
43
+ else str(raw)
44
+ ).strip()
45
+ if not line or not line.startswith("data:"):
46
+ continue
47
+ body = line[5:].strip()
48
+ if not body or body == "[DONE]":
49
+ continue
50
+ try:
51
+ payload = json.loads(body)
52
+ except ValueError:
53
+ continue
54
+ if isinstance(payload, dict):
55
+ yield payload
56
+
57
+
58
+ @dataclass
59
+ class _ToolFragment:
60
+ call_id: str = ""
61
+ name: str = ""
62
+ arguments: str = ""
63
+
64
+
65
+ def collect_openai_chat_stream(
66
+ lines: Iterable[Any], policy: RunawayOutputPolicy | None = None
67
+ ) -> SseStreamCollection:
68
+ """Merge OpenAI chat-completion chunks into one non-streaming response."""
69
+
70
+ text_runaway = RunawayOutputDetector(policy)
71
+ reasoning_runaway = RunawayOutputDetector(policy)
72
+ verdict: RunawayVerdict | None = None
73
+ content: list[str] = []
74
+ reasoning: list[str] = []
75
+ fragments: dict[int, _ToolFragment] = {}
76
+ finish_reason = ""
77
+ usage: dict[str, Any] = {}
78
+ envelope: dict[str, Any] = {}
79
+ chunks = 0
80
+ for payload in iter_sse_payloads(lines):
81
+ chunks += 1
82
+ for key in ("id", "model", "created", "system_fingerprint"):
83
+ if payload.get(key) is not None:
84
+ envelope[key] = payload[key]
85
+ if isinstance(payload.get("usage"), dict):
86
+ usage = payload["usage"]
87
+ choices = payload.get("choices")
88
+ choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
89
+ if choice.get("finish_reason"):
90
+ finish_reason = str(choice["finish_reason"])
91
+ delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
92
+ reasoning_chunk = str(delta.get("reasoning_content") or "")
93
+ if reasoning_chunk:
94
+ reasoning.append(reasoning_chunk)
95
+ verdict = verdict or reasoning_runaway.feed(reasoning_chunk)
96
+ text_chunk = str(delta.get("content") or "")
97
+ if text_chunk:
98
+ content.append(text_chunk)
99
+ verdict = verdict or text_runaway.feed(text_chunk)
100
+ for call in delta.get("tool_calls") or []:
101
+ if not isinstance(call, dict):
102
+ continue
103
+ try:
104
+ index = int(call.get("index") or 0)
105
+ except (TypeError, ValueError):
106
+ index = 0
107
+ fragment = fragments.setdefault(index, _ToolFragment())
108
+ if call.get("id"):
109
+ fragment.call_id = str(call["id"])
110
+ function = call.get("function") if isinstance(call.get("function"), dict) else {}
111
+ if function.get("name"):
112
+ fragment.name = str(function["name"])
113
+ if function.get("arguments"):
114
+ fragment.arguments += str(function["arguments"])
115
+ if verdict is not None:
116
+ break
117
+ message: dict[str, Any] = {"role": "assistant", "content": "".join(content)}
118
+ if reasoning:
119
+ message["reasoning_content"] = "".join(reasoning)
120
+ if fragments:
121
+ message["tool_calls"] = [
122
+ {
123
+ "id": fragment.call_id or f"call_{index + 1}",
124
+ "type": "function",
125
+ "function": {"name": fragment.name, "arguments": fragment.arguments},
126
+ }
127
+ for index, fragment in sorted(fragments.items())
128
+ ]
129
+ response = {
130
+ **envelope,
131
+ "choices": [{"index": 0, "message": message, "finish_reason": finish_reason or "stop"}],
132
+ }
133
+ if usage:
134
+ response["usage"] = usage
135
+ return SseStreamCollection(response=response, verdict=verdict, chunks=chunks)
136
+
137
+
138
+ @dataclass
139
+ class _ContentBlock:
140
+ block: dict[str, Any] = field(default_factory=dict)
141
+ text: list[str] = field(default_factory=list)
142
+ thinking: list[str] = field(default_factory=list)
143
+ signature: str = ""
144
+ partial_json: str = ""
145
+
146
+ def finish(self) -> dict[str, Any]:
147
+ block = dict(self.block)
148
+ kind = str(block.get("type") or "")
149
+ if kind == "text":
150
+ block["text"] = str(block.get("text") or "") + "".join(self.text)
151
+ elif kind in ("thinking", "redacted_thinking"):
152
+ block["thinking"] = str(block.get("thinking") or "") + "".join(self.thinking)
153
+ if self.signature:
154
+ block["signature"] = self.signature
155
+ elif kind == "tool_use" and self.partial_json:
156
+ try:
157
+ parsed = json.loads(self.partial_json)
158
+ except ValueError:
159
+ parsed = None
160
+ block["input"] = parsed if isinstance(parsed, dict) else block.get("input") or {}
161
+ return block
162
+
163
+
164
+ def collect_anthropic_message_stream(
165
+ lines: Iterable[Any], policy: RunawayOutputPolicy | None = None
166
+ ) -> SseStreamCollection:
167
+ """Merge Anthropic Messages SSE events into one non-streaming message."""
168
+
169
+ text_runaway = RunawayOutputDetector(policy)
170
+ thinking_runaway = RunawayOutputDetector(policy)
171
+ verdict: RunawayVerdict | None = None
172
+ message: dict[str, Any] = {
173
+ "type": "message",
174
+ "role": "assistant",
175
+ "content": [],
176
+ "stop_reason": None,
177
+ }
178
+ blocks: dict[int, _ContentBlock] = {}
179
+ chunks = 0
180
+ for payload in iter_sse_payloads(lines):
181
+ chunks += 1
182
+ event_type = str(payload.get("type") or "")
183
+ if event_type == "message_start":
184
+ started = payload.get("message")
185
+ if isinstance(started, dict):
186
+ message.update({key: value for key, value in started.items() if key != "content"})
187
+ continue
188
+ if event_type == "content_block_start":
189
+ index = payload.get("index")
190
+ block = payload.get("content_block")
191
+ if isinstance(index, int) and isinstance(block, dict):
192
+ blocks[index] = _ContentBlock(block=dict(block))
193
+ continue
194
+ if event_type == "content_block_delta":
195
+ index = payload.get("index")
196
+ delta = payload.get("delta") if isinstance(payload.get("delta"), dict) else {}
197
+ if not isinstance(index, int):
198
+ continue
199
+ state = blocks.setdefault(index, _ContentBlock(block={"type": "text", "text": ""}))
200
+ delta_type = str(delta.get("type") or "")
201
+ if delta_type == "text_delta":
202
+ chunk = str(delta.get("text") or "")
203
+ state.text.append(chunk)
204
+ verdict = verdict or text_runaway.feed(chunk)
205
+ elif delta_type == "thinking_delta":
206
+ chunk = str(delta.get("thinking") or "")
207
+ state.thinking.append(chunk)
208
+ verdict = verdict or thinking_runaway.feed(chunk)
209
+ elif delta_type == "signature_delta":
210
+ state.signature += str(delta.get("signature") or "")
211
+ elif delta_type == "input_json_delta":
212
+ state.partial_json += str(delta.get("partial_json") or "")
213
+ if verdict is not None:
214
+ break
215
+ continue
216
+ if event_type == "message_delta":
217
+ delta = payload.get("delta") if isinstance(payload.get("delta"), dict) else {}
218
+ for key in ("stop_reason", "stop_sequence"):
219
+ if delta.get(key) is not None:
220
+ message[key] = delta[key]
221
+ usage = payload.get("usage")
222
+ if isinstance(usage, dict):
223
+ message["usage"] = {**(message.get("usage") or {}), **usage}
224
+ continue
225
+ message["content"] = [state.finish() for _index, state in sorted(blocks.items())]
226
+ if verdict is not None and not message.get("stop_reason"):
227
+ message["stop_reason"] = "max_tokens"
228
+ return SseStreamCollection(response=message, verdict=verdict, chunks=chunks)
229
+
230
+
231
+ __all__ = [
232
+ "SseStreamCollection",
233
+ "collect_anthropic_message_stream",
234
+ "collect_openai_chat_stream",
235
+ "iter_sse_payloads",
236
+ ]