@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,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
|
+
]
|