@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
@@ -34,6 +34,95 @@ def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolic
34
34
  migrations = {}
35
35
  cfg["migrations"] = migrations
36
36
 
37
+ marker = "alibaba_provider_catalogs_20260806"
38
+ if not migrations.get(marker):
39
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
40
+ catalogs = {
41
+ "alicode": (
42
+ "qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5",
43
+ "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23",
44
+ "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7",
45
+ ),
46
+ "alicode-intl": (
47
+ "qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5",
48
+ "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23",
49
+ "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7",
50
+ ),
51
+ "alims-intl": (
52
+ "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
53
+ "qwen3.6-flash", "qwen3.5-plus", "qwen3-coder-plus",
54
+ "qwen3-coder-flash", "deepseek-v4-pro", "deepseek-v4-flash",
55
+ "glm-5.2", "kimi-k2.7-code", "MiniMax-M2.5",
56
+ ),
57
+ }
58
+ for provider_name, models in catalogs.items():
59
+ pcfg = providers.get(provider_name)
60
+ if not isinstance(pcfg, dict):
61
+ continue
62
+ custom = pcfg.get("custom_models")
63
+ if not isinstance(custom, list):
64
+ custom = []
65
+ pcfg["custom_models"] = custom
66
+ known = {
67
+ normalize_model_id(provider_name, str(model))
68
+ for model in custom
69
+ if str(model).strip()
70
+ }
71
+ for model in models:
72
+ if normalize_model_id(provider_name, model) not in known:
73
+ custom.append(model)
74
+ migrations[marker] = True
75
+
76
+ marker = "alibaba_token_plan_singapore_20260806"
77
+ if not migrations.get(marker):
78
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
79
+ pcfg = providers.get("alitoken")
80
+ if isinstance(pcfg, dict):
81
+ custom = pcfg.get("custom_models")
82
+ if not isinstance(custom, list):
83
+ custom = []
84
+ pcfg["custom_models"] = custom
85
+ models = (
86
+ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus",
87
+ "qwen3.6-plus", "qwen3.6-flash", "deepseek-v4-pro",
88
+ "deepseek-v4-flash", "deepseek-v3.2", "kimi-k2.7-code",
89
+ "kimi-k2.6", "kimi-k2.5", "glm-5.2", "glm-5.1", "glm-5",
90
+ "MiniMax-M2.5",
91
+ )
92
+ known = {
93
+ normalize_model_id("alitoken", str(model))
94
+ for model in custom
95
+ if str(model).strip()
96
+ }
97
+ custom.extend(
98
+ model
99
+ for model in models
100
+ if normalize_model_id("alitoken", model) not in known
101
+ )
102
+ pcfg["region"] = "ap-southeast-1"
103
+ migrations[marker] = True
104
+
105
+ marker = "alibaba_native_anthropic_routes_20260806"
106
+ if not migrations.get(marker):
107
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
108
+ for provider_name in (
109
+ "alicode", "alicode-intl", "alims-intl", "alitoken",
110
+ "alitoken-individual",
111
+ ):
112
+ pcfg = providers.get(provider_name)
113
+ if isinstance(pcfg, dict):
114
+ pcfg["native_compat"] = True
115
+ migrations[marker] = True
116
+
117
+ marker = "alibaba_token_plan_individual_20260806"
118
+ if not migrations.get(marker):
119
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
120
+ pcfg = providers.get("alitoken-individual")
121
+ if isinstance(pcfg, dict):
122
+ pcfg["region"] = "ap-southeast-1"
123
+ pcfg["native_compat"] = True
124
+ migrations[marker] = True
125
+
37
126
  marker = "ollama_cloud_deepseek_v4_flash_0731_20260803"
38
127
  if not migrations.get(marker):
39
128
  pcfg = cfg.get("providers", {}).get("ollama-cloud", {})
@@ -329,4 +418,18 @@ def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolic
329
418
  pcfg["model_profile"] = "kimi-k3-1m"
330
419
  migrations[marker] = True
331
420
 
421
+ marker = "kimi_k3_official_max_effort_20260806"
422
+ if not migrations.get(marker):
423
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
424
+ pcfg = providers.get("kimi")
425
+ if isinstance(pcfg, dict):
426
+ current = normalize_model_id("kimi", str(pcfg.get("current_model") or ""))
427
+ if (
428
+ current in {KIMI_K3_MODEL, f"{KIMI_K3_MODEL}[1m]"}
429
+ and pcfg.get("model_profile") == "kimi-k3-1m"
430
+ and str(pcfg.get("effort_level") or "").lower() == "high"
431
+ ):
432
+ pcfg["effort_level"] = "max"
433
+ migrations[marker] = True
434
+
332
435
  __all__ = ["ConfigMigrationPolicy", "apply_config_migrations"]
@@ -26,6 +26,36 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
26
26
  "port": 0,
27
27
  "tailscale_https": False,
28
28
  },
29
+ "speech": {
30
+ "asr": {
31
+ "enabled": False,
32
+ "base_url": "http://ciel-asr:8000",
33
+ "endpoint": "/v1/audio/transcriptions",
34
+ "model": "Qwen/Qwen3-ASR-0.6B",
35
+ "language": "auto",
36
+ "api_key": "",
37
+ "timeout_seconds": 300,
38
+ },
39
+ "tts": {
40
+ "enabled": False,
41
+ "base_url": "http://ciel-tts:8091",
42
+ "endpoint": "/v1/audio/speech",
43
+ "voices_endpoint": "/v1/audio/voices",
44
+ "model": "OpenMOSS-Team/MOSS-TTS-Nano",
45
+ "voice": "default",
46
+ "language": "ko",
47
+ "response_format": "wav",
48
+ "speed": 1.0,
49
+ "auto_speak": False,
50
+ "api_key": "",
51
+ "timeout_seconds": 300,
52
+ },
53
+ "tailscale": {
54
+ "enabled": True,
55
+ "asr_hostname": "ciel-asr",
56
+ "tts_hostname": "ciel-tts",
57
+ },
58
+ },
29
59
  "claude_code": {
30
60
  "compat_prompt_for_non_anthropic": True,
31
61
  "channels": [],
@@ -364,6 +364,44 @@ class ConfigurationCliController:
364
364
  return aliases.get(normalized, normalized)
365
365
 
366
366
 
367
+ @dataclass(frozen=True, slots=True)
368
+ class ConfigurationCliCompatibilityApi:
369
+ controller: Callable[[], ConfigurationCliController]
370
+
371
+ def provider(self, args: Any) -> None:
372
+ self.controller().provider_command(args.name)
373
+
374
+ def base_url(self, args: Any) -> None:
375
+ self.controller().base_url_command(args.provider, args.url)
376
+
377
+ def model(self, args: Any) -> None:
378
+ self.controller().model_command(args.value)
379
+
380
+ def advisor_model(self, args: Any) -> None:
381
+ self.controller().advisor_model_command(args.value)
382
+
383
+ def models(self, args: Any) -> None:
384
+ self.controller().models_command(args.provider)
385
+
386
+ def log_level(self, args: Any) -> None:
387
+ self.controller().log_level_command(getattr(args, "value", None))
388
+
389
+ def language(self, args: Any) -> None:
390
+ self.controller().language_command(args.value)
391
+
392
+ def web_search(self, args: Any) -> None:
393
+ self.controller().web_search_command(args.value)
394
+
395
+ def web_fetch(self, args: Any) -> None:
396
+ self.controller().web_fetch_command(args.value)
397
+
398
+ def portable_provider_menu(self) -> int:
399
+ return self.controller().portable_provider_menu()
400
+
401
+ def portable_language_menu(self) -> int:
402
+ return self.controller().portable_language_menu()
403
+
404
+
367
405
  __all__ = [
368
406
  "ConfigurationCliConfigPorts",
369
407
  "ConfigurationCliController",
@@ -38,6 +38,7 @@ class ContextCompactionProjection:
38
38
  build_fallback_summary: Callable[..., str]
39
39
  build_reduce_prompt: Callable[..., str]
40
40
  log: Callable[[str, str], None]
41
+ apply_ollama_optional: Callable[..., dict[str, Any]]
41
42
 
42
43
 
43
44
  @dataclass(frozen=True)
@@ -69,11 +70,15 @@ def request_context_summary(
69
70
  {"role": "user", "content": prompt},
70
71
  ],
71
72
  "stream": False,
72
- "think": False,
73
- "options": {"num_predict": max_tokens},
74
73
  }
75
- if provider_config.get("keep_alive"):
76
- request["keep_alive"] = str(provider_config["keep_alive"])
74
+ request = services.projection.apply_ollama_optional(
75
+ request,
76
+ provider,
77
+ model,
78
+ provider_config,
79
+ {},
80
+ output_limit=max_tokens,
81
+ )
77
82
  operation = "ollama_chat"
78
83
  elif wire == "openai":
79
84
  request = {
@@ -33,6 +33,7 @@ class CredentialPersistencePorts:
33
33
  clear_requested: Callable[[Any], bool]
34
34
  rotation_name: Callable[[str, dict[str, Any]], str]
35
35
  error_text: Callable[[Any], bool] = lambda _value: False
36
+ plausible: Callable[[Any], bool] = lambda value: bool(str(value or "").strip())
36
37
 
37
38
 
38
39
  @dataclass(frozen=True, slots=True)
@@ -125,6 +126,7 @@ class CredentialManagementService:
125
126
  def store_one(self, provider: str, key: str) -> list[str]:
126
127
  if self.persistence.clear_requested(key):
127
128
  return self.clear(provider)
129
+ self._reject_invalid_keys([key])
128
130
  config = self.persistence.load_config()
129
131
  provider_config = config["providers"][provider]
130
132
  if self.external.enabled(provider):
@@ -174,6 +176,7 @@ class CredentialManagementService:
174
176
  return self.clear(provider)
175
177
  if not parsed:
176
178
  raise SystemExit("No API keys provided; unchanged.")
179
+ self._reject_invalid_keys(parsed)
177
180
  config = self.persistence.load_config()
178
181
  provider_config = config["providers"][provider]
179
182
  provider_config["api_key"] = parsed[0]
@@ -215,6 +218,15 @@ class CredentialManagementService:
215
218
  return self.store_one(provider, keys[0])
216
219
  raise SystemExit("No API key provided; unchanged.")
217
220
 
221
+ def _reject_invalid_keys(self, keys: list[str]) -> None:
222
+ invalid = [key for key in keys if not self.persistence.plausible(key)]
223
+ if invalid:
224
+ raise SystemExit(
225
+ "Refusing to store an API key with an invalid token shape "
226
+ f"({len(invalid)} item(s)). Control characters and whitespace "
227
+ "are not valid inside API keys. Unchanged."
228
+ )
229
+
218
230
  @staticmethod
219
231
  def _snapshot_other_provider_keys(
220
232
  providers: dict[str, Any], target_provider: str
@@ -58,6 +58,18 @@ def plausible_api_key(value: Any) -> bool:
58
58
  if not _KEY_PLAUSIBLE_RE.match(text):
59
59
  return False
60
60
  return not looks_like_error_text(text)
61
+
62
+
63
+ def transportable_api_key(value: Any) -> bool:
64
+ """Reject control/whitespace corruption without assuming provider key length."""
65
+
66
+ text = str(value or "")
67
+ return bool(
68
+ text
69
+ and text == text.strip()
70
+ and not looks_like_error_text(text)
71
+ and not any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in text)
72
+ )
61
73
  SECRET_TEXT_PATTERNS = (
62
74
  re.compile(r"ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+"),
63
75
  re.compile(r"(AINET_API_KEY\s*=\s*)(\S+)", re.IGNORECASE),
@@ -20,8 +20,8 @@ GITHUB_USER_URL = "https://api.github.com/user"
20
20
  GITHUB_COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
21
21
  GITHUB_API_VERSION = "2022-11-28"
22
22
  COPILOT_API_VERSION = "2025-04-01"
23
- COPILOT_VSCODE_VERSION = "1.110.0"
24
- COPILOT_CHAT_VERSION = "0.38.0"
23
+ COPILOT_VSCODE_VERSION = "1.128.0"
24
+ COPILOT_CHAT_VERSION = "0.43.0"
25
25
  COPILOT_USER_AGENT = f"GitHubCopilotChat/{COPILOT_CHAT_VERSION}"
26
26
 
27
27
 
@@ -0,0 +1,216 @@
1
+ """Append-only orchestration for provider-hosted Formula tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import threading
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from typing import Any, Callable, Mapping
12
+
13
+ from .architecture import HostedToolPolicy
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class HostedFormulaState:
18
+ formula_by_function: Mapping[str, str]
19
+ headers: Mapping[str, str]
20
+ policy: HostedToolPolicy
21
+
22
+ @property
23
+ def enabled(self) -> bool:
24
+ return bool(self.formula_by_function)
25
+
26
+
27
+ class HostedFormulaToolService:
28
+ """Load Formula schemas, execute calls, and append results to chat history."""
29
+
30
+ def __init__(
31
+ self,
32
+ policy_for: Callable[[str, dict[str, Any]], HostedToolPolicy],
33
+ log: Callable[[str, str], None],
34
+ *,
35
+ open_url: Callable[..., Any] = urllib.request.urlopen,
36
+ max_rounds: int = 8,
37
+ ) -> None:
38
+ self._policy_for = policy_for
39
+ self._log = log
40
+ self._open_url = open_url
41
+ self._max_rounds = max(1, max_rounds)
42
+ self._cache: dict[tuple[str, str], tuple[dict[str, Any], ...]] = {}
43
+ self._unavailable: set[str] = set()
44
+ self._lock = threading.Lock()
45
+
46
+ @staticmethod
47
+ def _auth_headers(headers: Mapping[str, str]) -> dict[str, str]:
48
+ projected = {"content-type": "application/json"}
49
+ authorization = headers.get("authorization") or headers.get("Authorization")
50
+ if authorization:
51
+ projected["authorization"] = str(authorization)
52
+ return projected
53
+
54
+ def _json_request(
55
+ self,
56
+ url: str,
57
+ headers: Mapping[str, str],
58
+ *,
59
+ payload: Mapping[str, Any] | None = None,
60
+ timeout: float,
61
+ ) -> dict[str, Any]:
62
+ data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
63
+ request = urllib.request.Request(
64
+ url,
65
+ data=data,
66
+ headers=dict(headers),
67
+ method="GET" if data is None else "POST",
68
+ )
69
+ with self._open_url(request, timeout=timeout) as response:
70
+ decoded = json.loads(response.read().decode("utf-8", errors="replace"))
71
+ if not isinstance(decoded, dict):
72
+ raise RuntimeError("hosted Formula API returned non-object JSON")
73
+ return decoded
74
+
75
+ @staticmethod
76
+ def _formula_path(formula: str) -> str:
77
+ return urllib.parse.quote(formula, safe="/:")
78
+
79
+ def _tools_for_formula(
80
+ self,
81
+ policy: HostedToolPolicy,
82
+ formula: str,
83
+ headers: Mapping[str, str],
84
+ timeout: float,
85
+ ) -> tuple[dict[str, Any], ...]:
86
+ cache_key = (policy.base_url, formula)
87
+ with self._lock:
88
+ cached = self._cache.get(cache_key)
89
+ if cached is not None:
90
+ return cached
91
+ payload = self._json_request(
92
+ f"{policy.base_url}/formulas/{self._formula_path(formula)}/tools",
93
+ headers,
94
+ timeout=timeout,
95
+ )
96
+ tools = tuple(copy.deepcopy(tool) for tool in payload.get("tools", ()) if isinstance(tool, dict))
97
+ with self._lock:
98
+ self._cache[cache_key] = tools
99
+ return tools
100
+
101
+ def prepare(
102
+ self,
103
+ provider: str,
104
+ config: dict[str, Any],
105
+ request_body: dict[str, Any],
106
+ upstream_headers: Mapping[str, str],
107
+ timeout: float,
108
+ ) -> tuple[dict[str, Any], HostedFormulaState]:
109
+ policy = self._policy_for(provider, config)
110
+ auth_headers = self._auth_headers(upstream_headers)
111
+ empty = HostedFormulaState({}, auth_headers, policy)
112
+ if not policy.base_url or not policy.formulas or "authorization" not in auth_headers:
113
+ return request_body, empty
114
+ with self._lock:
115
+ if policy.base_url in self._unavailable:
116
+ return request_body, empty
117
+ timeout = min(max(1.0, timeout), 10.0)
118
+ existing = list(request_body.get("tools") or [])
119
+ used_names = {
120
+ str(tool.get("function", {}).get("name") or "")
121
+ for tool in existing
122
+ if isinstance(tool, dict) and isinstance(tool.get("function"), dict)
123
+ }
124
+ formula_by_function: dict[str, str] = {}
125
+ try:
126
+ for formula in policy.formulas:
127
+ for tool in self._tools_for_formula(policy, formula, auth_headers, timeout):
128
+ function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
129
+ name = str(function.get("name") or "")
130
+ if not name or name in used_names:
131
+ continue
132
+ used_names.add(name)
133
+ formula_by_function[name] = formula
134
+ existing.append(tool)
135
+ except Exception as exc:
136
+ with self._lock:
137
+ self._unavailable.add(policy.base_url)
138
+ self._log("WARN", f"hosted Formula tools unavailable for {provider}: {type(exc).__name__}: {exc}")
139
+ return request_body, empty
140
+ if not formula_by_function:
141
+ return request_body, empty
142
+ prepared = {**request_body, "tools": existing, "stream": False}
143
+ return prepared, HostedFormulaState(formula_by_function, auth_headers, policy)
144
+
145
+ def _execute(
146
+ self,
147
+ state: HostedFormulaState,
148
+ function: Mapping[str, Any],
149
+ timeout: float,
150
+ ) -> str:
151
+ name = str(function.get("name") or "")
152
+ formula = state.formula_by_function[name]
153
+ payload = self._json_request(
154
+ f"{state.policy.base_url}/formulas/{self._formula_path(formula)}/fibers",
155
+ state.headers,
156
+ payload={"function": {"name": name, "arguments": str(function.get("arguments") or "{}")}},
157
+ timeout=timeout,
158
+ )
159
+ context = payload.get("context") if isinstance(payload.get("context"), dict) else {}
160
+ output = context.get("output")
161
+ if output is None:
162
+ output = context.get("encrypted_output")
163
+ if isinstance(output, str):
164
+ return output
165
+ return json.dumps(output if output is not None else payload, ensure_ascii=False)
166
+
167
+ def resolve(
168
+ self,
169
+ state: HostedFormulaState,
170
+ request_body: dict[str, Any],
171
+ response: dict[str, Any],
172
+ post_chat: Callable[[dict[str, Any]], dict[str, Any]],
173
+ timeout: float,
174
+ ) -> dict[str, Any]:
175
+ if not state.enabled:
176
+ return response
177
+ body = copy.deepcopy(request_body)
178
+ current = response
179
+ for _round in range(self._max_rounds):
180
+ choices = current.get("choices") if isinstance(current, dict) else None
181
+ choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
182
+ message = choice.get("message") if isinstance(choice.get("message"), dict) else {}
183
+ calls = [call for call in message.get("tool_calls", ()) if isinstance(call, dict)]
184
+ if not calls:
185
+ return current
186
+ functions = [call.get("function") if isinstance(call.get("function"), dict) else {} for call in calls]
187
+ if any(str(function.get("name") or "") not in state.formula_by_function for function in functions):
188
+ return current
189
+ body.setdefault("messages", []).append(copy.deepcopy(message))
190
+ for call, function in zip(calls, functions):
191
+ body["messages"].append(
192
+ {
193
+ "role": "tool",
194
+ "tool_call_id": str(call.get("id") or "hosted_tool"),
195
+ "content": self._execute(state, function, timeout),
196
+ }
197
+ )
198
+ body["tool_choice"] = "auto"
199
+ current = post_chat(body)
200
+ raise RuntimeError(f"hosted Formula tool loop exceeded {self._max_rounds} rounds")
201
+
202
+
203
+ __all__ = ["HostedFormulaState", "HostedFormulaToolService"]
204
+
205
+
206
+ _SHARED_SERVICE: HostedFormulaToolService | None = None
207
+
208
+
209
+ def shared_service(
210
+ policy_for: Callable[[str, dict[str, Any]], HostedToolPolicy],
211
+ log: Callable[[str, str], None],
212
+ ) -> HostedFormulaToolService:
213
+ global _SHARED_SERVICE
214
+ if _SHARED_SERVICE is None:
215
+ _SHARED_SERVICE = HostedFormulaToolService(policy_for, log)
216
+ return _SHARED_SERVICE