@oneciel-ai/ciel-runtime 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ciel_runtime.py +2553 -9635
- package/ciel_runtime_support/advisor_request_builder.py +8 -21
- package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
- package/ciel_runtime_support/architecture.py +68 -0
- package/ciel_runtime_support/architecture_budget.py +1 -1
- package/ciel_runtime_support/channel_connection_context.py +233 -0
- package/ciel_runtime_support/channel_delivery_context.py +332 -0
- package/ciel_runtime_support/channel_mcp_context.py +313 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
- package/ciel_runtime_support/channel_mcp_transport.py +5 -1
- package/ciel_runtime_support/channel_message_context.py +212 -0
- package/ciel_runtime_support/channel_message_repository.py +14 -3
- package/ciel_runtime_support/channel_pending_injection.py +9 -0
- package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
- package/ciel_runtime_support/channel_replay_policy.py +38 -0
- package/ciel_runtime_support/channel_runtime_environment.py +8 -0
- package/ciel_runtime_support/channel_session_context.py +236 -0
- package/ciel_runtime_support/channel_terminal_context.py +350 -0
- package/ciel_runtime_support/channel_wake_context.py +532 -0
- package/ciel_runtime_support/claude_environment.py +60 -0
- package/ciel_runtime_support/claude_launch_assembly.py +249 -0
- package/ciel_runtime_support/claude_router.py +62 -12
- package/ciel_runtime_support/cli_application_context.py +132 -0
- package/ciel_runtime_support/cli_assembly.py +50 -0
- package/ciel_runtime_support/codex_backend_context.py +363 -0
- package/ciel_runtime_support/codex_config.py +13 -1
- package/ciel_runtime_support/codex_launch_assembly.py +213 -0
- package/ciel_runtime_support/codex_launch_configuration.py +30 -1
- package/ciel_runtime_support/codex_mcp_integration.py +90 -8
- package/ciel_runtime_support/codex_model_catalog.py +4 -1
- package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
- package/ciel_runtime_support/codex_router.py +38 -8
- package/ciel_runtime_support/codex_turn_recovery.py +154 -0
- package/ciel_runtime_support/config_migrations.py +103 -0
- package/ciel_runtime_support/configuration_cli.py +38 -0
- package/ciel_runtime_support/context_compaction.py +9 -4
- package/ciel_runtime_support/credential_management.py +12 -0
- package/ciel_runtime_support/credentials.py +12 -0
- package/ciel_runtime_support/github_copilot_oauth.py +2 -2
- package/ciel_runtime_support/hosted_formula_tools.py +216 -0
- package/ciel_runtime_support/kimi_runtime_context.py +208 -0
- package/ciel_runtime_support/llm_preset_context.py +338 -0
- package/ciel_runtime_support/managed_mcp_config.py +8 -4
- package/ciel_runtime_support/mcp_configuration_context.py +291 -0
- package/ciel_runtime_support/mcp_http_proxy.py +14 -8
- package/ciel_runtime_support/mcp_probe_transport.py +47 -15
- package/ciel_runtime_support/mcp_transport.py +14 -1
- package/ciel_runtime_support/native_context_recovery.py +72 -0
- package/ciel_runtime_support/ollama_catalog_context.py +213 -0
- package/ciel_runtime_support/ollama_stream_collection.py +103 -0
- package/ciel_runtime_support/ollama_thinking.py +6 -1
- package/ciel_runtime_support/ollama_wire_projection.py +157 -0
- package/ciel_runtime_support/openai_forwarding.py +32 -10
- package/ciel_runtime_support/openai_responses_router.py +12 -0
- package/ciel_runtime_support/package_lifecycle.py +39 -0
- package/ciel_runtime_support/prelaunch_assembly.py +37 -0
- package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
- package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
- package/ciel_runtime_support/prompt_compaction.py +144 -0
- package/ciel_runtime_support/prompt_injection.py +45 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
- package/ciel_runtime_support/protocols/chat_projection.py +85 -5
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
- package/ciel_runtime_support/protocols/ollama_response.py +57 -5
- package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
- package/ciel_runtime_support/protocols/openai_responses.py +61 -15
- package/ciel_runtime_support/provider_adapters.py +26 -0
- package/ciel_runtime_support/provider_administration_context.py +207 -0
- package/ciel_runtime_support/provider_config_mutations.py +3 -0
- package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
- package/ciel_runtime_support/provider_model_context.py +107 -0
- package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
- package/ciel_runtime_support/provider_model_selection.py +10 -3
- package/ciel_runtime_support/provider_models.py +45 -2
- package/ciel_runtime_support/provider_option_cli.py +19 -0
- package/ciel_runtime_support/provider_policy.py +1 -1
- package/ciel_runtime_support/provider_readiness_context.py +189 -0
- package/ciel_runtime_support/provider_request_builder.py +64 -28
- package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
- package/ciel_runtime_support/provider_timeout_policy.py +54 -0
- package/ciel_runtime_support/provider_tool_policy.py +9 -1
- package/ciel_runtime_support/providers/__init__.py +6 -0
- package/ciel_runtime_support/providers/alibaba.py +634 -0
- package/ciel_runtime_support/providers/catalog.py +24 -16
- package/ciel_runtime_support/providers/deepseek.py +73 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
- package/ciel_runtime_support/providers/kimi.py +69 -9
- package/ciel_runtime_support/providers/ollama.py +8 -0
- package/ciel_runtime_support/providers/ollama_context.py +21 -2
- package/ciel_runtime_support/providers/vllm.py +7 -1
- package/ciel_runtime_support/response_collection.py +68 -18
- package/ciel_runtime_support/response_collection_context.py +391 -0
- package/ciel_runtime_support/response_stream_context.py +555 -0
- package/ciel_runtime_support/responses_input_compatibility.py +121 -0
- package/ciel_runtime_support/responses_usage_observer.py +83 -0
- package/ciel_runtime_support/router_client_lifecycle.py +1 -0
- package/ciel_runtime_support/router_http.py +239 -3
- package/ciel_runtime_support/router_observability_context.py +251 -0
- package/ciel_runtime_support/router_process_context.py +200 -0
- package/ciel_runtime_support/router_process_lifecycle.py +2 -0
- package/ciel_runtime_support/router_request_assembly.py +399 -0
- package/ciel_runtime_support/router_request_context.py +215 -0
- package/ciel_runtime_support/router_server_context.py +82 -0
- package/ciel_runtime_support/runaway_output_guard.py +488 -0
- package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
- package/ciel_runtime_support/runtime_asset_context.py +297 -0
- package/ciel_runtime_support/runtime_constants.py +16 -1
- package/ciel_runtime_support/runtime_launch.py +9 -5
- package/ciel_runtime_support/runtime_launch_context.py +130 -0
- package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
- package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
- package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
- package/ciel_runtime_support/runtime_paths.py +60 -40
- package/ciel_runtime_support/runtime_primitives.py +78 -0
- package/ciel_runtime_support/sse_stream_collection.py +236 -0
- package/ciel_runtime_support/statusline_script.py +57 -8
- package/ciel_runtime_support/streaming_anthropic.py +361 -24
- package/ciel_runtime_support/tool_schema.py +40 -2
- package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
- package/ciel_runtime_support/upstream_dump.py +68 -0
- package/ciel_runtime_support/upstream_retry_context.py +259 -0
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
85
|
-
f"matched_name={tool_name!r}
|
|
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
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Select an isolated router port for the current working directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def workspace_identity(value: Any) -> str:
|
|
14
|
+
text = str(value or "").strip()
|
|
15
|
+
if not text:
|
|
16
|
+
return ""
|
|
17
|
+
try:
|
|
18
|
+
return os.path.normcase(str(Path(text).resolve(strict=False)))
|
|
19
|
+
except Exception:
|
|
20
|
+
return os.path.normcase(text)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def probe_router_health(port: int, timeout: float = 0.15) -> dict[str, Any] | None:
|
|
24
|
+
try:
|
|
25
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=timeout) as response:
|
|
26
|
+
payload = json.loads(response.read().decode("utf-8", errors="replace"))
|
|
27
|
+
return payload if isinstance(payload, dict) and payload.get("ok") is True else None
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def port_is_free(port: int) -> bool:
|
|
33
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
34
|
+
try:
|
|
35
|
+
probe.bind(("127.0.0.1", port))
|
|
36
|
+
return True
|
|
37
|
+
except OSError:
|
|
38
|
+
return False
|
|
39
|
+
finally:
|
|
40
|
+
probe.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def select_workspace_router_port(
|
|
44
|
+
base_port: int,
|
|
45
|
+
workspace: Path,
|
|
46
|
+
environ: Mapping[str, str],
|
|
47
|
+
*,
|
|
48
|
+
health: Callable[[int], dict[str, Any] | None] = probe_router_health,
|
|
49
|
+
available: Callable[[int], bool] = port_is_free,
|
|
50
|
+
scan_size: int = 32,
|
|
51
|
+
) -> int:
|
|
52
|
+
"""Reuse the same workspace port, otherwise select the first free port."""
|
|
53
|
+
|
|
54
|
+
if str(environ.get("CIEL_RUNTIME_ROUTER_PORT") or "").strip():
|
|
55
|
+
return base_port
|
|
56
|
+
target = workspace_identity(
|
|
57
|
+
environ.get("CIEL_RUNTIME_LAUNCH_CWD") or workspace
|
|
58
|
+
)
|
|
59
|
+
unknown_base_health: dict[str, Any] | None = None
|
|
60
|
+
for offset in range(max(1, scan_size)):
|
|
61
|
+
port = base_port + offset
|
|
62
|
+
if port > 65535:
|
|
63
|
+
break
|
|
64
|
+
observed = health(port)
|
|
65
|
+
if observed is not None:
|
|
66
|
+
running_workspace = workspace_identity(observed.get("workspace"))
|
|
67
|
+
if running_workspace and running_workspace == target:
|
|
68
|
+
return port
|
|
69
|
+
if offset == 0 and not running_workspace:
|
|
70
|
+
unknown_base_health = observed
|
|
71
|
+
continue
|
|
72
|
+
if available(port):
|
|
73
|
+
if offset == 0 and unknown_base_health is not None:
|
|
74
|
+
continue
|
|
75
|
+
return port
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"no free ciel-runtime router port found in {base_port}-{min(65535, base_port + scan_size - 1)}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"port_is_free",
|
|
83
|
+
"probe_router_health",
|
|
84
|
+
"select_workspace_router_port",
|
|
85
|
+
"workspace_identity",
|
|
86
|
+
]
|