@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
@@ -61,6 +61,13 @@ TASK_UPDATE_STATUS_ALIASES = {
61
61
  _TOOL_SCHEMA_REGISTRY: dict[str, dict[str, Any]] = {}
62
62
 
63
63
  _BUILTIN_TOOL_SCHEMAS: dict[str, dict[str, Any]] = {
64
+ "shell_command": {
65
+ "required": ["command"],
66
+ "properties": {
67
+ "command": {"type": "string"},
68
+ "timeout_ms": {"type": "integer"},
69
+ },
70
+ },
64
71
  "Bash": {
65
72
  "required": ["command"],
66
73
  "properties": {
@@ -277,6 +284,18 @@ def _coerce_value(value: Any, expected_type: str | None) -> Any:
277
284
  return value
278
285
 
279
286
 
287
+ def _normalize_integral_numbers(value: Any) -> Any:
288
+ """Keep JSON-schema integer values integral even when a model emits `1.0`."""
289
+
290
+ if isinstance(value, float) and value.is_integer():
291
+ return int(value)
292
+ if isinstance(value, list):
293
+ return [_normalize_integral_numbers(item) for item in value]
294
+ if isinstance(value, dict):
295
+ return {key: _normalize_integral_numbers(item) for key, item in value.items()}
296
+ return value
297
+
298
+
280
299
  def normalize_task_update_status(value: Any) -> str | None:
281
300
  if value is None:
282
301
  return None
@@ -337,6 +356,7 @@ def _validate_and_fix_tool_input(
337
356
  - add defaults for missing required fields
338
357
  - keep unknown fields (Claude Code may accept extra fields)
339
358
  """
359
+ input_dict = _normalize_integral_numbers(input_dict)
340
360
  schema = tool_schema_in_body(source_body, tool_name) if isinstance(source_body, dict) else None
341
361
  if schema is None:
342
362
  schema = _lookup_tool_schema(tool_name)
@@ -453,10 +473,28 @@ def _validate_and_fix_tool_input(
453
473
  if injected:
454
474
  (log or _noop_log)("WARN", f"tool_guard: {matched_name}: injected missing required fields: {', '.join(injected)}")
455
475
 
456
- return fixed
476
+ # A client-provided schema may describe an integer-valued field as the
477
+ # broader JSON Schema ``number`` type. Coercion above then creates a
478
+ # float even when the value is integral, while native clients such as
479
+ # Codex deserialize timeout fields as u64. Normalize once more at the
480
+ # serialization boundary so a repaired 120000 never leaves as 120000.0.
481
+ return _normalize_integral_numbers(fixed)
457
482
 
458
483
 
459
484
  def _missing_required_tool_fields(tool_name: str, input_dict: dict[str, Any], source_body: dict[str, Any] | None = None) -> list[str]:
485
+ """Report required fields the caller never supplied.
486
+
487
+ JSON Schema ``required`` constrains key *presence*, not emptiness, and an
488
+ empty value is meaningful for several Claude Code tools: ``Edit`` deletes
489
+ text with ``new_string: ""`` and ``Write`` creates an empty file with
490
+ ``content: ""``. Treating those as missing discards a valid edit.
491
+
492
+ Pass the model's own arguments here, not a repaired copy.
493
+ ``_validate_and_fix_tool_input`` injects a typed empty value for every
494
+ absent required field, so an emptiness test applied after repair cannot
495
+ tell an invented value from one the model actually chose.
496
+ """
497
+
460
498
  schema = tool_schema_in_body(source_body, tool_name) if isinstance(source_body, dict) else None
461
499
  if schema is None:
462
500
  schema = _lookup_tool_schema(tool_name)
@@ -469,7 +507,7 @@ def _missing_required_tool_fields(tool_name: str, input_dict: dict[str, Any], so
469
507
  for field in required:
470
508
  if not isinstance(field, str):
471
509
  continue
472
- if field not in input_dict or _is_empty_value(input_dict.get(field)):
510
+ if field not in input_dict:
473
511
  missing.append(field)
474
512
  return missing
475
513
 
@@ -12,6 +12,15 @@ from typing import Any, Callable
12
12
  class ToolSideEffectDedupePolicy:
13
13
  side_effect_suffixes: frozenset[str]
14
14
  ttl_seconds: float = 600.0
15
+ repeated_execution_suffixes: frozenset[str] = frozenset(
16
+ {"shell_command", "bash", "exec", "execute", "run_command", "write", "edit", "apply_patch"}
17
+ )
18
+ # One completed call can legitimately be retried after a client/process
19
+ # interruption because the resumed request carries no explicit new user
20
+ # message. Only stop the call after the same successful result has already
21
+ # been observed twice consecutively. This still bounds genuine model loops
22
+ # without treating a single historical completion as a permanent lock.
23
+ completed_repeat_limit: int = 2
15
24
 
16
25
 
17
26
  class ToolSideEffectDedupeRepository:
@@ -60,29 +69,125 @@ class ToolSideEffectDedupeService:
60
69
  digest = hashlib.sha256(payload.encode("utf-8", errors="replace")).hexdigest()
61
70
  return f"{normalized_name}:{digest}"
62
71
 
63
- def should_drop(self, tool_name: str, tool_input: dict[str, Any], raw_name: str = "") -> bool:
72
+ @staticmethod
73
+ def _normalized_payload(value: Any) -> Any:
74
+ if isinstance(value, float) and value.is_integer():
75
+ return int(value)
76
+ if isinstance(value, list):
77
+ return [ToolSideEffectDedupeService._normalized_payload(item) for item in value]
78
+ if isinstance(value, dict):
79
+ return {
80
+ key: ToolSideEffectDedupeService._normalized_payload(item)
81
+ for key, item in value.items()
82
+ }
83
+ return value
84
+
85
+ @classmethod
86
+ def _signature(cls, tool_name: str, tool_input: Any) -> str:
87
+ payload = json.dumps(
88
+ cls._normalized_payload(tool_input if isinstance(tool_input, dict) else {}),
89
+ sort_keys=True,
90
+ ensure_ascii=False,
91
+ separators=(",", ":"),
92
+ default=str,
93
+ )
94
+ return f"{tool_name}:{payload}"
95
+
96
+ def completed_repeat_count(
97
+ self, source_body: dict[str, Any] | None, tool_name: str, tool_input: dict[str, Any]
98
+ ) -> int:
99
+ if not isinstance(source_body, dict):
100
+ return 0
101
+ tool_leaf = tool_name.rsplit("__", 1)[-1].strip().lower()
102
+ if tool_leaf not in self.policy.repeated_execution_suffixes:
103
+ return 0
104
+ target = self._signature(tool_name, tool_input)
105
+ tool_uses: dict[str, str] = {}
106
+ completed: list[str | None] = []
107
+ for message in source_body.get("messages") or []:
108
+ if not isinstance(message, dict):
109
+ continue
110
+ content = message.get("content")
111
+ blocks = content if isinstance(content, list) else []
112
+ if message.get("role") == "assistant":
113
+ for block in blocks:
114
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
115
+ continue
116
+ tool_id = str(block.get("id") or "")
117
+ if tool_id:
118
+ tool_uses[tool_id] = self._signature(
119
+ str(block.get("name") or ""), block.get("input")
120
+ )
121
+ continue
122
+ if message.get("role") != "user":
123
+ continue
124
+ results = [
125
+ block
126
+ for block in blocks
127
+ if isinstance(block, dict) and block.get("type") == "tool_result"
128
+ ]
129
+ has_new_intent = isinstance(content, str) and bool(content.strip())
130
+ has_new_intent = has_new_intent or any(
131
+ not isinstance(block, dict) or block.get("type") != "tool_result"
132
+ for block in blocks
133
+ )
134
+ if has_new_intent:
135
+ completed.clear()
136
+ for block in results:
137
+ signature = tool_uses.get(str(block.get("tool_use_id") or ""))
138
+ completed.append(None if block.get("is_error") else signature)
139
+ count = 0
140
+ for signature in reversed(completed):
141
+ if signature != target:
142
+ break
143
+ count += 1
144
+ return count
145
+
146
+ def should_drop(
147
+ self,
148
+ tool_name: str,
149
+ tool_input: dict[str, Any],
150
+ raw_name: str = "",
151
+ source_body: dict[str, Any] | None = None,
152
+ ) -> bool:
64
153
  key = self.key(tool_name, tool_input)
65
- if not key:
66
- return False
67
- now = self.ports.now()
68
- previous = self.repository.previous_or_record(key, now, self.policy.ttl_seconds)
69
- if previous is None:
154
+ if key:
155
+ now = self.ports.now()
156
+ previous = self.repository.previous_or_record(key, now, self.policy.ttl_seconds)
157
+ if previous is not None:
158
+ age = now - previous
159
+ self.ports.audit(
160
+ "dropped_duplicate_side_effect_tool_call",
161
+ {
162
+ "raw_name": raw_name or tool_name,
163
+ "matched_name": tool_name,
164
+ "emitted_input": tool_input,
165
+ "age_seconds": round(age, 3),
166
+ "ttl_seconds": self.policy.ttl_seconds,
167
+ },
168
+ )
169
+ self.ports.log(
170
+ "WARN",
171
+ f"dropped duplicate side-effect tool call raw_name={raw_name or tool_name!r} "
172
+ f"matched_name={tool_name!r} age={age:.1f}s",
173
+ )
174
+ return True
175
+ repeat_count = self.completed_repeat_count(source_body, tool_name, tool_input)
176
+ if repeat_count < max(1, self.policy.completed_repeat_limit):
70
177
  return False
71
- age = now - previous
72
178
  self.ports.audit(
73
- "dropped_duplicate_side_effect_tool_call",
179
+ "dropped_repeated_completed_tool_call",
74
180
  {
75
181
  "raw_name": raw_name or tool_name,
76
182
  "matched_name": tool_name,
77
183
  "emitted_input": tool_input,
78
- "age_seconds": round(age, 3),
79
- "ttl_seconds": self.policy.ttl_seconds,
184
+ "completed_repeats": repeat_count,
80
185
  },
81
186
  )
82
187
  self.ports.log(
83
188
  "WARN",
84
- f"dropped duplicate side-effect tool call raw_name={raw_name or tool_name!r} "
85
- f"matched_name={tool_name!r} age={age:.1f}s",
189
+ f"dropped repeated completed tool call raw_name={raw_name or tool_name!r} "
190
+ f"matched_name={tool_name!r} completed_repeats={repeat_count}",
86
191
  )
87
192
  return True
88
193
 
@@ -0,0 +1,68 @@
1
+ """Opt-in capture of the exact bytes sent to an upstream provider.
2
+
3
+ Set ``CIEL_RUNTIME_DUMP_UPSTREAM`` to a directory to record every upstream
4
+ request body the router is about to send on its provider-wire paths. Each
5
+ request produces two files sharing one stem:
6
+
7
+ ``upstream-<utc>-<seq>-body.json``
8
+ The request body exactly as encoded for the wire, byte for byte.
9
+ ``upstream-<utc>-<seq>-meta.json``
10
+ Where it was going: URL, byte count, and capture time.
11
+
12
+ The variable is read per request, capture failures only log, and nothing is
13
+ written when the variable is unset, so the feature is inert in normal runs.
14
+ This exists to diagnose upstream rejections from evidence instead of from
15
+ reconstructions of what the router "should" have sent.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import itertools
21
+ import json
22
+ import os
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any, Callable
26
+
27
+ DUMP_ENV_VAR = "CIEL_RUNTIME_DUMP_UPSTREAM"
28
+
29
+ _sequence = itertools.count(1)
30
+
31
+
32
+ def upstream_dump_dir(env: Callable[[str], str | None] = os.environ.get) -> Path | None:
33
+ configured = (env(DUMP_ENV_VAR) or "").strip()
34
+ return Path(configured) if configured else None
35
+
36
+
37
+ def dump_upstream_request(
38
+ url: str,
39
+ data: bytes,
40
+ log: Callable[[str, str], Any],
41
+ env: Callable[[str], str | None] = os.environ.get,
42
+ ) -> Path | None:
43
+ """Record ``data`` exactly as it will be sent to ``url``; never raise."""
44
+
45
+ target = upstream_dump_dir(env)
46
+ if target is None:
47
+ return None
48
+ try:
49
+ target.mkdir(parents=True, exist_ok=True)
50
+ stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
51
+ stem = f"upstream-{stamp}-{next(_sequence):04d}"
52
+ body_path = target / f"{stem}-body.json"
53
+ body_path.write_bytes(data)
54
+ (target / f"{stem}-meta.json").write_text(
55
+ json.dumps(
56
+ {"time": stamp, "url": url, "body_bytes": len(data)},
57
+ ensure_ascii=False,
58
+ ),
59
+ encoding="utf-8",
60
+ )
61
+ log("INFO", f"upstream_dump wrote {body_path.name} bytes={len(data)} url={url}")
62
+ return body_path
63
+ except Exception as exc:
64
+ log("WARN", f"upstream_dump_failed error={type(exc).__name__}: {exc}")
65
+ return None
66
+
67
+
68
+ __all__ = ["DUMP_ENV_VAR", "dump_upstream_request", "upstream_dump_dir"]
@@ -0,0 +1,259 @@
1
+ """Upstream retry, credential rotation, and rate-limit bounded context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.error
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ from .upstream_retry import (
10
+ UpstreamRetryHttp,
11
+ UpstreamRetryKeys,
12
+ UpstreamRetryPolicy,
13
+ UpstreamRetryRateLimit,
14
+ UpstreamRetryServices,
15
+ open_openai_stream_with_rate_retry,
16
+ open_provider_request_with_key_retry,
17
+ post_json_with_rate_retry,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class UpstreamRetryErrorPorts:
23
+ project_http_error: Callable[..., str]
24
+ project_retry_message: Callable[..., str]
25
+ first_header: Callable[..., Any]
26
+ parse_retry_after: Callable[..., Any]
27
+ format_duration: Callable[..., str]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class UpstreamRetryPolicyPorts:
32
+ configured_retries: Callable[[dict[str, Any]], int]
33
+ retry_after_exceeds_timeout: Callable[..., bool]
34
+ retryable_exception: Callable[[BaseException], bool]
35
+ retry_wait_seconds: Callable[..., float]
36
+ retry_http_codes: frozenset[int]
37
+ language: Callable[[], str]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class UpstreamRetryCredentialPorts:
42
+ key_from_headers: Callable[..., Any]
43
+ api_key_count: Callable[..., int]
44
+ has_live_api_key: Callable[..., bool]
45
+ headers: Callable[..., dict[str, str]]
46
+ register_cooldown: Callable[..., Any]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class UpstreamRetryRateLimitPorts:
51
+ learn_headers: Callable[..., Any]
52
+ log: Callable[..., Any]
53
+ register_backoff: Callable[..., Any]
54
+ write_activity: Callable[..., Any]
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class UpstreamRetryTransportPorts:
59
+ estimate_tokens: Callable[..., int]
60
+ urlopen: Callable[..., Any]
61
+ set_stream_read_timeout: Callable[..., Any]
62
+ stream_idle_timeout_seconds: Callable[..., float]
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class UpstreamRetryContext:
67
+ errors: UpstreamRetryErrorPorts
68
+ policy: UpstreamRetryPolicyPorts
69
+ credentials: UpstreamRetryCredentialPorts
70
+ rate_limit: UpstreamRetryRateLimitPorts
71
+ transport: UpstreamRetryTransportPorts
72
+
73
+ def http_error_message(
74
+ self,
75
+ exc: urllib.error.HTTPError,
76
+ raw: str | None = None,
77
+ ) -> str:
78
+ return self.errors.project_http_error(
79
+ exc,
80
+ raw,
81
+ first_header=self.errors.first_header,
82
+ parse_retry_after=self.errors.parse_retry_after,
83
+ format_duration=self.errors.format_duration,
84
+ )
85
+
86
+ def retry_message(self, attempt: int, total: int) -> str:
87
+ return self.errors.project_retry_message(
88
+ self.policy.language(), attempt, total
89
+ )
90
+
91
+ def rate_limit_retry_message(self, attempt: int, total: int) -> str:
92
+ return self.errors.project_retry_message(
93
+ self.policy.language(), attempt, total, rate_limit=True
94
+ )
95
+
96
+ def services(self) -> UpstreamRetryServices:
97
+ return UpstreamRetryServices(
98
+ policy=UpstreamRetryPolicy(
99
+ configured_gateway_retries=self.policy.configured_retries,
100
+ retry_after_exceeds_request_timeout=(
101
+ self.policy.retry_after_exceeds_timeout
102
+ ),
103
+ retryable_upstream_exception=self.policy.retryable_exception,
104
+ upstream_rate_limit_retry_message=self.rate_limit_retry_message,
105
+ upstream_retry_http_codes=self.policy.retry_http_codes,
106
+ upstream_retry_message=self.retry_message,
107
+ upstream_retry_wait_seconds=self.policy.retry_wait_seconds,
108
+ ),
109
+ keys=UpstreamRetryKeys(
110
+ key_from_request_headers=self.credentials.key_from_headers,
111
+ provider_api_key_count=self.credentials.api_key_count,
112
+ provider_has_live_api_key=self.credentials.has_live_api_key,
113
+ provider_headers=self.credentials.headers,
114
+ register_api_key_cooldown=self.credentials.register_cooldown,
115
+ ),
116
+ rate_limit=UpstreamRetryRateLimit(
117
+ learn_headers=self.rate_limit.learn_headers,
118
+ log=self.rate_limit.log,
119
+ register_backoff=self.rate_limit.register_backoff,
120
+ write_activity=self.rate_limit.write_activity,
121
+ ),
122
+ http=UpstreamRetryHttp(
123
+ estimate_tokens=self.transport.estimate_tokens,
124
+ provider_urlopen=self.transport.urlopen,
125
+ set_stream_read_timeout=self.transport.set_stream_read_timeout,
126
+ stream_idle_timeout_seconds=(
127
+ self.transport.stream_idle_timeout_seconds
128
+ ),
129
+ upstream_http_error_message=self.http_error_message,
130
+ ),
131
+ )
132
+
133
+ def post_json(
134
+ self,
135
+ url: str,
136
+ req_body: Any,
137
+ headers: dict[str, str],
138
+ timeout: float,
139
+ provider: str,
140
+ pcfg: dict[str, Any],
141
+ model: str,
142
+ retry_notice: Callable[[str], None] | None = None,
143
+ *,
144
+ retry_rate_limits: bool = True,
145
+ ) -> Any:
146
+ return post_json_with_rate_retry(
147
+ url,
148
+ req_body,
149
+ headers,
150
+ timeout,
151
+ provider,
152
+ pcfg,
153
+ model,
154
+ retry_notice,
155
+ retry_rate_limits=retry_rate_limits,
156
+ services=self.services(),
157
+ )
158
+
159
+ def open_provider_request(
160
+ self,
161
+ url: str,
162
+ req_body: Any,
163
+ headers: dict[str, str],
164
+ timeout: float,
165
+ provider: str,
166
+ pcfg: dict[str, Any],
167
+ model: str,
168
+ *,
169
+ stream: bool = False,
170
+ retry_rate_limits: bool = True,
171
+ ) -> Any:
172
+ return open_provider_request_with_key_retry(
173
+ url,
174
+ req_body,
175
+ headers,
176
+ timeout,
177
+ provider,
178
+ pcfg,
179
+ model,
180
+ stream=stream,
181
+ retry_rate_limits=retry_rate_limits,
182
+ services=self.services(),
183
+ )
184
+
185
+ def open_openai_stream(
186
+ self,
187
+ url: str,
188
+ req_body: Any,
189
+ headers: dict[str, str],
190
+ timeout: float,
191
+ provider: str,
192
+ pcfg: dict[str, Any],
193
+ model: str,
194
+ retry_notice: Callable[[str], None] | None = None,
195
+ *,
196
+ retry_rate_limits: bool = True,
197
+ ) -> Any:
198
+ return open_openai_stream_with_rate_retry(
199
+ url,
200
+ req_body,
201
+ headers,
202
+ timeout,
203
+ provider,
204
+ pcfg,
205
+ model,
206
+ retry_notice,
207
+ retry_rate_limits=retry_rate_limits,
208
+ services=self.services(),
209
+ )
210
+
211
+
212
+ @dataclass(frozen=True, slots=True)
213
+ class UpstreamRetryCompatibilityApi:
214
+ context: Callable[[], UpstreamRetryContext]
215
+
216
+ def http_error_message(
217
+ self,
218
+ exc: urllib.error.HTTPError,
219
+ raw: str | None = None,
220
+ ) -> str:
221
+ return self.context().http_error_message(exc, raw)
222
+
223
+ def retry_message(self, attempt: int, total: int) -> str:
224
+ return self.context().retry_message(attempt, total)
225
+
226
+ def rate_limit_retry_message(self, attempt: int, total: int) -> str:
227
+ return self.context().rate_limit_retry_message(attempt, total)
228
+
229
+ def retry_wait_seconds(self, *args: Any, **kwargs: Any) -> float:
230
+ return self.context().policy.retry_wait_seconds(*args, **kwargs)
231
+
232
+ def retryable_exception(self, exc: BaseException) -> bool:
233
+ return self.context().policy.retryable_exception(exc)
234
+
235
+ def configured_retries(self, pcfg: dict[str, Any]) -> int:
236
+ return self.context().policy.configured_retries(pcfg)
237
+
238
+ def services(self) -> UpstreamRetryServices:
239
+ return self.context().services()
240
+
241
+ def post_json(self, *args: Any, **kwargs: Any) -> Any:
242
+ return self.context().post_json(*args, **kwargs)
243
+
244
+ def open_provider_request(self, *args: Any, **kwargs: Any) -> Any:
245
+ return self.context().open_provider_request(*args, **kwargs)
246
+
247
+ def open_openai_stream(self, *args: Any, **kwargs: Any) -> Any:
248
+ return self.context().open_openai_stream(*args, **kwargs)
249
+
250
+
251
+ __all__ = [
252
+ "UpstreamRetryCompatibilityApi",
253
+ "UpstreamRetryContext",
254
+ "UpstreamRetryCredentialPorts",
255
+ "UpstreamRetryErrorPorts",
256
+ "UpstreamRetryPolicyPorts",
257
+ "UpstreamRetryRateLimitPorts",
258
+ "UpstreamRetryTransportPorts",
259
+ ]