@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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import re
|
|
6
7
|
from collections.abc import Callable, Iterable
|
|
7
8
|
from dataclasses import dataclass
|
|
@@ -23,9 +24,15 @@ class CodexMcpArtifactPorts:
|
|
|
23
24
|
load_json: Callable[[Path], Any]
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class CodexMcpProbeCachePorts:
|
|
29
|
+
ensure: Callable[..., Any]
|
|
30
|
+
refresh: Callable[..., Any]
|
|
31
|
+
|
|
32
|
+
|
|
26
33
|
@dataclass(frozen=True, slots=True)
|
|
27
34
|
class CodexMcpCapabilityPorts:
|
|
28
|
-
|
|
35
|
+
probe_cache: CodexMcpProbeCachePorts
|
|
29
36
|
read_servers: Callable[[Path, Path], list[dict[str, Any]]]
|
|
30
37
|
cached_probe_servers: Callable[[], list[dict[str, Any]]]
|
|
31
38
|
path_key: Callable[[Path], str]
|
|
@@ -99,7 +106,7 @@ class CodexMcpIntegrationService:
|
|
|
99
106
|
) -> list[str]:
|
|
100
107
|
if not config_path or not config_path.exists() or not config_path.is_file():
|
|
101
108
|
return []
|
|
102
|
-
self.capability.
|
|
109
|
+
self.capability.probe_cache.ensure(
|
|
103
110
|
cfg, [], extra_config_paths=[config_path]
|
|
104
111
|
)
|
|
105
112
|
candidate_names = [
|
|
@@ -110,9 +117,43 @@ class CodexMcpIntegrationService:
|
|
|
110
117
|
if str(server.get("channel") or "").strip()
|
|
111
118
|
]
|
|
112
119
|
source_key = self.capability.path_key(config_path)
|
|
120
|
+
records = self.capability.cached_probe_servers()
|
|
121
|
+
source_names = {
|
|
122
|
+
str(record.get("name") or "").strip()
|
|
123
|
+
for record in records
|
|
124
|
+
if str(record.get("name") or "").strip()
|
|
125
|
+
and self.capability.path_key(
|
|
126
|
+
Path(str(record.get("source_path") or ""))
|
|
127
|
+
)
|
|
128
|
+
== source_key
|
|
129
|
+
}
|
|
130
|
+
missing_source_names = sorted(set(candidate_names) - source_names)
|
|
131
|
+
if missing_source_names:
|
|
132
|
+
# A name-only probe cache can contain a valid result from a stale
|
|
133
|
+
# source file while the generated Codex projection has different
|
|
134
|
+
# authentication headers. Refresh from the generated artifact so
|
|
135
|
+
# the notification worker is selected from the exact config it
|
|
136
|
+
# will use, rather than silently falling back to Codex's native
|
|
137
|
+
# connection (which Ciel cannot inject from).
|
|
138
|
+
try:
|
|
139
|
+
self.capability.probe_cache.refresh(
|
|
140
|
+
[], extra_config_paths=[config_path]
|
|
141
|
+
)
|
|
142
|
+
records = self.capability.cached_probe_servers()
|
|
143
|
+
self.config.log(
|
|
144
|
+
"INFO",
|
|
145
|
+
"codex_mcp_channel_probe_refreshed "
|
|
146
|
+
f"source={config_path} missing={','.join(missing_source_names)}",
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
self.config.log(
|
|
150
|
+
"WARN",
|
|
151
|
+
"codex_mcp_channel_probe_refresh_failed "
|
|
152
|
+
f"source={config_path} error={type(exc).__name__}: {exc}",
|
|
153
|
+
)
|
|
113
154
|
capable = {
|
|
114
155
|
str(record.get("name") or "").strip()
|
|
115
|
-
for record in
|
|
156
|
+
for record in records
|
|
116
157
|
if record.get("capable")
|
|
117
158
|
and str(record.get("name") or "").strip()
|
|
118
159
|
and self.capability.path_key(
|
|
@@ -151,6 +192,49 @@ class CodexMcpIntegrationService:
|
|
|
151
192
|
and self.projection.is_streamable_http(raw_server)
|
|
152
193
|
}
|
|
153
194
|
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _notification_owner_identity(server: dict[str, Any]) -> str:
|
|
197
|
+
"""Identify aliases that would open the same authenticated HTTP stream."""
|
|
198
|
+
projected = {
|
|
199
|
+
"url": str(server.get("url") or server.get("endpoint") or "")
|
|
200
|
+
.strip()
|
|
201
|
+
.rstrip("/"),
|
|
202
|
+
"headers": server.get("headers")
|
|
203
|
+
if isinstance(server.get("headers"), dict)
|
|
204
|
+
else {},
|
|
205
|
+
"env_http_headers": server.get("env_http_headers")
|
|
206
|
+
if isinstance(server.get("env_http_headers"), dict)
|
|
207
|
+
else {},
|
|
208
|
+
"bearer_token": str(server.get("bearer_token") or server.get("token") or ""),
|
|
209
|
+
"bearer_token_env_var": str(
|
|
210
|
+
server.get("bearer_token_env_var") or server.get("token_env_var") or ""
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
return json.dumps(projected, sort_keys=True, separators=(",", ":"), default=str)
|
|
214
|
+
|
|
215
|
+
def channel_owned_aliases(
|
|
216
|
+
self,
|
|
217
|
+
servers: dict[str, dict[str, Any]],
|
|
218
|
+
channel_owned_server_names: Iterable[str] | None,
|
|
219
|
+
) -> set[str]:
|
|
220
|
+
requested = {
|
|
221
|
+
self.projection.public_name(str(name or "").strip())
|
|
222
|
+
for name in channel_owned_server_names or []
|
|
223
|
+
if str(name or "").strip()
|
|
224
|
+
}
|
|
225
|
+
owned_identities = {
|
|
226
|
+
self._notification_owner_identity(server)
|
|
227
|
+
for name, server in servers.items()
|
|
228
|
+
if self.projection.public_name(name) in requested
|
|
229
|
+
}
|
|
230
|
+
if not owned_identities:
|
|
231
|
+
return requested
|
|
232
|
+
return {
|
|
233
|
+
self.projection.public_name(name)
|
|
234
|
+
for name, server in servers.items()
|
|
235
|
+
if self._notification_owner_identity(server) in owned_identities
|
|
236
|
+
}
|
|
237
|
+
|
|
154
238
|
def native_http_compat_args(
|
|
155
239
|
self,
|
|
156
240
|
config_path: Path | None,
|
|
@@ -167,11 +251,9 @@ class CodexMcpIntegrationService:
|
|
|
167
251
|
url = self.projection.toml_string(self.policy.builtin_channel_url())
|
|
168
252
|
args.extend(["-c", f"mcp_servers.{key}.url={url}"])
|
|
169
253
|
active.append(key)
|
|
170
|
-
channel_owned =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if str(name or "").strip()
|
|
174
|
-
}
|
|
254
|
+
channel_owned = self.channel_owned_aliases(
|
|
255
|
+
servers, channel_owned_server_names
|
|
256
|
+
)
|
|
175
257
|
for name in sorted(servers):
|
|
176
258
|
key = self.config_bare_key(name)
|
|
177
259
|
if not key:
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
from collections.abc import Callable
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
9
|
+
from typing import Any, Mapping
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@dataclass(frozen=True, slots=True)
|
|
@@ -16,6 +16,7 @@ class CodexModelCatalogSpec:
|
|
|
16
16
|
context_window: int
|
|
17
17
|
effort: str = ""
|
|
18
18
|
auto_compact_token_limit: int | None = None
|
|
19
|
+
metadata: Mapping[str, Any] | None = None
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class CodexModelCatalogService:
|
|
@@ -106,6 +107,8 @@ class CodexModelCatalogService:
|
|
|
106
107
|
),
|
|
107
108
|
}
|
|
108
109
|
)
|
|
110
|
+
if spec.metadata:
|
|
111
|
+
routed.update(json.loads(json.dumps(dict(spec.metadata))))
|
|
109
112
|
if spec.effort:
|
|
110
113
|
routed["default_reasoning_level"] = spec.effort
|
|
111
114
|
supported = routed.get("supported_reasoning_levels")
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Remove sealed reasoning that the Codex backend itself refused to verify.
|
|
2
|
+
|
|
3
|
+
A Codex session can move between providers (routed GitHub Copilot, Alibaba,
|
|
4
|
+
back to an OpenAI account). Each provider seals its reasoning items with its
|
|
5
|
+
own keys, Codex persists them, and on resume it replays them with their item
|
|
6
|
+
IDs stripped — so a replayed reasoning item carries no provenance the router
|
|
7
|
+
could inspect. Only the upstream can tell whose ciphertext it is, and when it
|
|
8
|
+
is not its own the OpenAI backend rejects the whole turn:
|
|
9
|
+
|
|
10
|
+
invalid_request_error: The encrypted content oPYR...Lj8= could not be
|
|
11
|
+
verified. Reason: Encrypted content could not be decrypted or parsed.
|
|
12
|
+
|
|
13
|
+
This module turns that explicit verdict into the removal rule. The error names
|
|
14
|
+
the ciphertext's head and tail; the matching reasoning item is dropped and the
|
|
15
|
+
request retried, and the verdict is persisted (as a SHA-256 of the ciphertext)
|
|
16
|
+
so later turns strip it before sending. Nothing is guessed: every removal
|
|
17
|
+
traces back to an upstream rejection of that exact ciphertext, sealed
|
|
18
|
+
reasoning the upstream accepts is never touched, and the session file is
|
|
19
|
+
never modified.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
_UNVERIFIABLE_CONTENT = re.compile(
|
|
31
|
+
r"encrypted content ([A-Za-z0-9+/=_-]+)\.\.\.([A-Za-z0-9+/=_-]+) "
|
|
32
|
+
r"could not be verified"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_MISSING_ITEM = re.compile(r"[Ii]tem with id '([^']+)' not found")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_unverifiable_encrypted_content(error_text: str) -> tuple[str, str] | None:
|
|
39
|
+
"""Extract the (head, tail) of the ciphertext an upstream rejected."""
|
|
40
|
+
|
|
41
|
+
match = _UNVERIFIABLE_CONTENT.search(str(error_text or ""))
|
|
42
|
+
return (match.group(1), match.group(2)) if match else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_missing_item_id(error_text: str) -> str | None:
|
|
46
|
+
"""Extract the item ID an upstream says it never stored.
|
|
47
|
+
|
|
48
|
+
Switching providers mid-session leaves items whose IDs were minted by the
|
|
49
|
+
router or by another backend. Replaying one draws a 404: ``Item with id
|
|
50
|
+
'rs_e50d87c9_0' not found. Items are not persisted when 'store' is set to
|
|
51
|
+
false. Try again with 'store' set to true, or remove this item from your
|
|
52
|
+
input.`` The upstream names the offending item, so the removal needs no
|
|
53
|
+
guesswork.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
match = _MISSING_ITEM.search(str(error_text or ""))
|
|
57
|
+
return match.group(1) if match else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _replayed_without_id(item: dict[str, Any]) -> dict[str, Any] | None:
|
|
61
|
+
"""Rebuild one item so it replays inline, or drop it when it carries nothing.
|
|
62
|
+
|
|
63
|
+
A reasoning summary has no signature and no conversation content, so it is
|
|
64
|
+
dropped outright. Everything else — messages, tool calls, tool outputs — is
|
|
65
|
+
kept with its ID omitted, which preserves the turn's context and the
|
|
66
|
+
``call_id`` pairing while removing the reference the upstream rejected.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
if item.get("type") == "reasoning" and not str(
|
|
70
|
+
item.get("encrypted_content") or ""
|
|
71
|
+
).strip():
|
|
72
|
+
return None
|
|
73
|
+
retained = dict(item)
|
|
74
|
+
retained.pop("id", None)
|
|
75
|
+
return retained
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def repair_unstored_items(body: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
|
79
|
+
"""Replay every identified item inline after the upstream refuses one.
|
|
80
|
+
|
|
81
|
+
The rejection is not about one unlucky item. ``Items are not persisted when
|
|
82
|
+
'store' is set to false`` states that the backend resolves no item ID at
|
|
83
|
+
all, so a session carrying IDs minted elsewhere has as many unknown items
|
|
84
|
+
as it has replayed ones. Repairing them one verdict at a time costs one
|
|
85
|
+
round trip each — thousands of them on a long cross-provider session, with
|
|
86
|
+
nothing reaching the client meanwhile, which reads as a silent freeze.
|
|
87
|
+
Strip them in a single pass instead, so the turn recovers in one retry and
|
|
88
|
+
a second rejection of the same kind can find nothing left to repair.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
items = body.get("input")
|
|
92
|
+
if not isinstance(items, list):
|
|
93
|
+
return body, 0
|
|
94
|
+
repaired: list[Any] = []
|
|
95
|
+
changed = 0
|
|
96
|
+
for item in items:
|
|
97
|
+
if not isinstance(item, dict) or not item.get("id"):
|
|
98
|
+
repaired.append(item)
|
|
99
|
+
continue
|
|
100
|
+
changed += 1
|
|
101
|
+
replacement = _replayed_without_id(item)
|
|
102
|
+
if replacement is not None:
|
|
103
|
+
repaired.append(replacement)
|
|
104
|
+
if not changed:
|
|
105
|
+
return body, 0
|
|
106
|
+
projected = dict(body)
|
|
107
|
+
projected["input"] = repaired
|
|
108
|
+
return projected, changed
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def encrypted_content_digest(encrypted_content: str) -> str:
|
|
112
|
+
return hashlib.sha256(encrypted_content.encode("utf-8")).hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RejectedReasoningStore:
|
|
116
|
+
"""Durable record of ciphertexts the upstream refused to verify."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, path: Path, log: Callable[[str, str], Any]) -> None:
|
|
119
|
+
self._path = path
|
|
120
|
+
self._log = log
|
|
121
|
+
self._digests: set[str] | None = None
|
|
122
|
+
|
|
123
|
+
def _load(self) -> set[str]:
|
|
124
|
+
if self._digests is not None:
|
|
125
|
+
return self._digests
|
|
126
|
+
digests: set[str] = set()
|
|
127
|
+
try:
|
|
128
|
+
recorded = json.loads(self._path.read_text(encoding="utf-8"))
|
|
129
|
+
entries = recorded.get("sha256") if isinstance(recorded, dict) else None
|
|
130
|
+
if isinstance(entries, list):
|
|
131
|
+
digests = {str(entry) for entry in entries}
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
pass
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
self._log(
|
|
136
|
+
"WARN",
|
|
137
|
+
f"rejected_reasoning_store_load_failed error={type(exc).__name__}: {exc}",
|
|
138
|
+
)
|
|
139
|
+
self._digests = digests
|
|
140
|
+
return digests
|
|
141
|
+
|
|
142
|
+
def contains(self, encrypted_content: str) -> bool:
|
|
143
|
+
return encrypted_content_digest(encrypted_content) in self._load()
|
|
144
|
+
|
|
145
|
+
def add(self, encrypted_content: str) -> None:
|
|
146
|
+
digests = self._load()
|
|
147
|
+
digest = encrypted_content_digest(encrypted_content)
|
|
148
|
+
if digest in digests:
|
|
149
|
+
return
|
|
150
|
+
digests.add(digest)
|
|
151
|
+
try:
|
|
152
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
self._path.write_text(
|
|
154
|
+
json.dumps({"version": 1, "sha256": sorted(digests)}),
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
self._log(
|
|
159
|
+
"WARN",
|
|
160
|
+
f"rejected_reasoning_store_write_failed error={type(exc).__name__}: {exc}",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def drop_rejected_reasoning(
|
|
165
|
+
body: dict[str, Any],
|
|
166
|
+
is_rejected: Callable[[str], bool],
|
|
167
|
+
) -> tuple[dict[str, Any], int]:
|
|
168
|
+
"""Strip reasoning items whose ciphertext the upstream already rejected."""
|
|
169
|
+
|
|
170
|
+
items = body.get("input")
|
|
171
|
+
if not isinstance(items, list):
|
|
172
|
+
return body, 0
|
|
173
|
+
kept: list[Any] = []
|
|
174
|
+
dropped = 0
|
|
175
|
+
for item in items:
|
|
176
|
+
if (
|
|
177
|
+
isinstance(item, dict)
|
|
178
|
+
and item.get("type") == "reasoning"
|
|
179
|
+
and isinstance(item.get("encrypted_content"), str)
|
|
180
|
+
and is_rejected(item["encrypted_content"])
|
|
181
|
+
):
|
|
182
|
+
dropped += 1
|
|
183
|
+
continue
|
|
184
|
+
kept.append(item)
|
|
185
|
+
if not dropped:
|
|
186
|
+
return body, 0
|
|
187
|
+
projected = dict(body)
|
|
188
|
+
projected["input"] = kept
|
|
189
|
+
return projected, dropped
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def drop_reasoning_matching_verdict(
|
|
193
|
+
body: dict[str, Any],
|
|
194
|
+
head: str,
|
|
195
|
+
tail: str,
|
|
196
|
+
) -> tuple[dict[str, Any], str | None]:
|
|
197
|
+
"""Drop the reasoning item whose ciphertext the upstream just named."""
|
|
198
|
+
|
|
199
|
+
items = body.get("input")
|
|
200
|
+
if not isinstance(items, list):
|
|
201
|
+
return body, None
|
|
202
|
+
for index, item in enumerate(items):
|
|
203
|
+
if not isinstance(item, dict) or item.get("type") != "reasoning":
|
|
204
|
+
continue
|
|
205
|
+
sealed = item.get("encrypted_content")
|
|
206
|
+
if (
|
|
207
|
+
isinstance(sealed, str)
|
|
208
|
+
and sealed.startswith(head)
|
|
209
|
+
and sealed.endswith(tail)
|
|
210
|
+
):
|
|
211
|
+
projected = dict(body)
|
|
212
|
+
projected["input"] = items[:index] + items[index + 1 :]
|
|
213
|
+
return projected, sealed
|
|
214
|
+
return body, None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = [
|
|
218
|
+
"RejectedReasoningStore",
|
|
219
|
+
"drop_rejected_reasoning",
|
|
220
|
+
"drop_reasoning_matching_verdict",
|
|
221
|
+
"encrypted_content_digest",
|
|
222
|
+
"parse_missing_item_id",
|
|
223
|
+
"parse_unverifiable_encrypted_content",
|
|
224
|
+
"repair_unstored_items",
|
|
225
|
+
]
|
|
@@ -10,14 +10,24 @@ from .agent_router import COMMON_RUNTIME_ROUTER_CAPABILITIES, RouterCapability
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
CODEX_CAPACITY_ERROR_CODES = frozenset({"server_is_overloaded", "slow_down"})
|
|
13
|
+
# The upstream reports an oversized turn the same way it reports overload: a
|
|
14
|
+
# 200 response whose first events are control-only, ending in response.failed.
|
|
15
|
+
# Retrying it unchanged cannot succeed, so it is classified apart from capacity.
|
|
16
|
+
CODEX_CONTEXT_ERROR_CODES = frozenset({"context_length_exceeded"})
|
|
13
17
|
CODEX_RESPONSE_PREAMBLE_LIMIT = 256 * 1024
|
|
14
|
-
|
|
18
|
+
# A bare `error` frame carries no model output and precedes response.failed in
|
|
19
|
+
# every captured refusal, so scanning must continue past it rather than treat it
|
|
20
|
+
# as the start of a reply.
|
|
21
|
+
_CODEX_NON_OUTPUT_EVENT_TYPES = frozenset(
|
|
22
|
+
{"response.created", "response.in_progress", "error"}
|
|
23
|
+
)
|
|
15
24
|
|
|
16
25
|
|
|
17
26
|
@dataclass(frozen=True)
|
|
18
27
|
class CodexResponsePreamble:
|
|
19
28
|
payload: bytes
|
|
20
29
|
capacity_error_code: str | None = None
|
|
30
|
+
context_error_code: str | None = None
|
|
21
31
|
|
|
22
32
|
|
|
23
33
|
@dataclass(frozen=True, slots=True)
|
|
@@ -86,19 +96,36 @@ def _codex_sse_event(block: bytes) -> dict[str, Any] | None:
|
|
|
86
96
|
return value if isinstance(value, dict) else None
|
|
87
97
|
|
|
88
98
|
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
def _codex_failure_code(event: dict[str, Any]) -> str | None:
|
|
100
|
+
"""Read the failure code from either shape the upstream uses.
|
|
101
|
+
|
|
102
|
+
A refusal arrives as a bare ``error`` frame and again inside the
|
|
103
|
+
``response.failed`` frame that follows it; both carry the same code.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
event_type = event.get("type")
|
|
107
|
+
if event_type == "error":
|
|
108
|
+
error = event.get("error")
|
|
109
|
+
elif event_type == "response.failed":
|
|
110
|
+
response = event.get("response")
|
|
111
|
+
error = response.get("error") if isinstance(response, dict) else None
|
|
112
|
+
else:
|
|
94
113
|
return None
|
|
95
|
-
error = response.get("error")
|
|
96
114
|
if not isinstance(error, dict):
|
|
97
115
|
return None
|
|
98
|
-
|
|
116
|
+
return str(error.get("code") or "").strip() or None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _codex_capacity_error_code(event: dict[str, Any]) -> str | None:
|
|
120
|
+
code = _codex_failure_code(event)
|
|
99
121
|
return code if code in CODEX_CAPACITY_ERROR_CODES else None
|
|
100
122
|
|
|
101
123
|
|
|
124
|
+
def _codex_context_error_code(event: dict[str, Any]) -> str | None:
|
|
125
|
+
code = _codex_failure_code(event)
|
|
126
|
+
return code if code in CODEX_CONTEXT_ERROR_CODES else None
|
|
127
|
+
|
|
128
|
+
|
|
102
129
|
def read_codex_response_preamble(
|
|
103
130
|
stream: Any,
|
|
104
131
|
*,
|
|
@@ -137,6 +164,9 @@ def read_codex_response_preamble(
|
|
|
137
164
|
capacity_code = _codex_capacity_error_code(event)
|
|
138
165
|
if capacity_code:
|
|
139
166
|
return CodexResponsePreamble(bytes(buffered), capacity_code)
|
|
167
|
+
context_code = _codex_context_error_code(event)
|
|
168
|
+
if context_code:
|
|
169
|
+
return CodexResponsePreamble(bytes(buffered), None, context_code)
|
|
140
170
|
if event.get("type") not in _CODEX_NON_OUTPUT_EVENT_TYPES:
|
|
141
171
|
return CodexResponsePreamble(bytes(buffered))
|
|
142
172
|
if reached_eof:
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Keep routed Codex turns alive when a model only announces its intent.
|
|
2
|
+
|
|
3
|
+
Claude sessions get two safeguards against a model that says "I'll start now"
|
|
4
|
+
and then ends the turn: a compatibility instruction appended at launch, and
|
|
5
|
+
TaskList synthesis in the Anthropic stream. Neither reaches Codex — the launch
|
|
6
|
+
flag is Claude-only and the recovery lives in the Anthropic protocol path — so
|
|
7
|
+
a routed Codex session simply stops. These helpers give the Responses path the
|
|
8
|
+
same protection without synthesizing a tool call the Codex client never offered.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CODEX_CONTINUATION_NUDGE = (
|
|
19
|
+
"Continue the work you just described in this same turn. Call the tools you "
|
|
20
|
+
"need now instead of restating the plan, and report the concrete result. Only "
|
|
21
|
+
"answer without tools when the task genuinely requires no tool use."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def message_text(message: dict[str, Any]) -> str:
|
|
26
|
+
parts: list[str] = []
|
|
27
|
+
for block in message.get("content") or []:
|
|
28
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
29
|
+
text = block.get("text")
|
|
30
|
+
if isinstance(text, str):
|
|
31
|
+
parts.append(text)
|
|
32
|
+
return "\n".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def message_has_tool_use(message: dict[str, Any]) -> bool:
|
|
36
|
+
return any(
|
|
37
|
+
isinstance(block, dict) and block.get("type") == "tool_use"
|
|
38
|
+
for block in message.get("content") or []
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def body_with_continuation_nudge(
|
|
43
|
+
body: dict[str, Any], message: dict[str, Any], nudge: str = CODEX_CONTINUATION_NUDGE
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Replay the request with the stalled reply and an explicit continue turn."""
|
|
46
|
+
|
|
47
|
+
messages = list(body.get("messages") or [])
|
|
48
|
+
assistant_text = message_text(message).strip()
|
|
49
|
+
if assistant_text:
|
|
50
|
+
messages.append({"role": "assistant", "content": [{"type": "text", "text": assistant_text}]})
|
|
51
|
+
messages.append({"role": "user", "content": [{"type": "text", "text": nudge}]})
|
|
52
|
+
retried = dict(body)
|
|
53
|
+
retried["messages"] = messages
|
|
54
|
+
return retried
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def body_with_codex_compat_instructions(
|
|
58
|
+
body: dict[str, Any],
|
|
59
|
+
compat_prompt: str,
|
|
60
|
+
*,
|
|
61
|
+
is_native_codex: bool,
|
|
62
|
+
compat_enabled: bool,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""Append the routed Codex compatibility instruction to a Responses request.
|
|
65
|
+
|
|
66
|
+
Codex rejects ``--append-system-prompt`` as a Claude-only flag, so the
|
|
67
|
+
instruction has to travel in the request body. The native Codex backend is
|
|
68
|
+
excluded: it serves OpenAI's own models, which do not need the nudge, and
|
|
69
|
+
rewriting instructions there would only invalidate the cached prefix.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
if not isinstance(body, dict) or is_native_codex or not compat_enabled:
|
|
73
|
+
return body
|
|
74
|
+
existing = str(body.get("instructions") or "")
|
|
75
|
+
if compat_prompt in existing:
|
|
76
|
+
return body
|
|
77
|
+
projected = dict(body)
|
|
78
|
+
projected["instructions"] = (
|
|
79
|
+
f"{existing.rstrip()}\n\n{compat_prompt}" if existing.strip() else compat_prompt
|
|
80
|
+
)
|
|
81
|
+
return projected
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class CodexTurnRecoveryServices:
|
|
86
|
+
should_retry: Callable[[dict[str, Any], str, list[Any]], bool]
|
|
87
|
+
collect_message: Callable[..., dict[str, Any]]
|
|
88
|
+
log: Callable[[str, str], Any]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def recover_preamble_only_turn(
|
|
92
|
+
handler: Any,
|
|
93
|
+
provider: str,
|
|
94
|
+
pcfg: dict[str, Any],
|
|
95
|
+
body: dict[str, Any],
|
|
96
|
+
message: dict[str, Any],
|
|
97
|
+
services: CodexTurnRecoveryServices,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Retry once when the model announced work but called no tool.
|
|
100
|
+
|
|
101
|
+
Bounded to a single extra upstream call. The retry only wins if it produces
|
|
102
|
+
a tool call; prose answers keep the original reply so a model that legitimately
|
|
103
|
+
responds without tools is never overridden or duplicated.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if not isinstance(message, dict) or message_has_tool_use(message):
|
|
107
|
+
return message
|
|
108
|
+
text = message_text(message)
|
|
109
|
+
if not services.should_retry(body, text, []):
|
|
110
|
+
return message
|
|
111
|
+
|
|
112
|
+
services.log(
|
|
113
|
+
"WARN",
|
|
114
|
+
f"codex_preamble_only_turn_retry provider={provider} "
|
|
115
|
+
f"model={str(body.get('model') or '-')} chars={len(text.strip())}",
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
retried = services.collect_message(
|
|
119
|
+
handler, provider, pcfg, body_with_continuation_nudge(body, message)
|
|
120
|
+
)
|
|
121
|
+
except Exception as exc: # noqa: BLE001 - recovery must never fail the turn
|
|
122
|
+
services.log(
|
|
123
|
+
"WARN",
|
|
124
|
+
f"codex_preamble_only_turn_retry_failed error={type(exc).__name__}: {exc}",
|
|
125
|
+
)
|
|
126
|
+
return message
|
|
127
|
+
if not isinstance(retried, dict) or not message_has_tool_use(retried):
|
|
128
|
+
return message
|
|
129
|
+
return _merged(message, retried)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _merged(original: dict[str, Any], retried: dict[str, Any]) -> dict[str, Any]:
|
|
133
|
+
"""Keep the announcement, then the work it promised."""
|
|
134
|
+
|
|
135
|
+
preamble = message_text(original).strip()
|
|
136
|
+
blocks = list(retried.get("content") or [])
|
|
137
|
+
if preamble:
|
|
138
|
+
retried_text = re.sub(r"\s+", " ", message_text(retried)).strip()
|
|
139
|
+
if re.sub(r"\s+", " ", preamble) not in retried_text:
|
|
140
|
+
blocks = [{"type": "text", "text": preamble}, *blocks]
|
|
141
|
+
merged = dict(retried)
|
|
142
|
+
merged["content"] = blocks
|
|
143
|
+
return merged
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"CODEX_CONTINUATION_NUDGE",
|
|
148
|
+
"CodexTurnRecoveryServices",
|
|
149
|
+
"body_with_codex_compat_instructions",
|
|
150
|
+
"body_with_continuation_nudge",
|
|
151
|
+
"message_has_tool_use",
|
|
152
|
+
"message_text",
|
|
153
|
+
"recover_preamble_only_turn",
|
|
154
|
+
]
|