@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.
Files changed (126) hide show
  1. package/ciel_runtime.py +2553 -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/configuration_cli.py +38 -0
  36. package/ciel_runtime_support/context_compaction.py +9 -4
  37. package/ciel_runtime_support/credential_management.py +12 -0
  38. package/ciel_runtime_support/credentials.py +12 -0
  39. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  40. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  41. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  42. package/ciel_runtime_support/llm_preset_context.py +338 -0
  43. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  44. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  45. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  46. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  47. package/ciel_runtime_support/mcp_transport.py +14 -1
  48. package/ciel_runtime_support/native_context_recovery.py +72 -0
  49. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  50. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  51. package/ciel_runtime_support/ollama_thinking.py +6 -1
  52. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  53. package/ciel_runtime_support/openai_forwarding.py +32 -10
  54. package/ciel_runtime_support/openai_responses_router.py +12 -0
  55. package/ciel_runtime_support/package_lifecycle.py +39 -0
  56. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  57. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  58. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  59. package/ciel_runtime_support/prompt_compaction.py +144 -0
  60. package/ciel_runtime_support/prompt_injection.py +45 -0
  61. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  62. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  63. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  64. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  65. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  66. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  67. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  68. package/ciel_runtime_support/provider_adapters.py +26 -0
  69. package/ciel_runtime_support/provider_administration_context.py +207 -0
  70. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  71. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  72. package/ciel_runtime_support/provider_model_context.py +107 -0
  73. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  74. package/ciel_runtime_support/provider_model_selection.py +10 -3
  75. package/ciel_runtime_support/provider_models.py +45 -2
  76. package/ciel_runtime_support/provider_option_cli.py +19 -0
  77. package/ciel_runtime_support/provider_policy.py +1 -1
  78. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  79. package/ciel_runtime_support/provider_request_builder.py +64 -28
  80. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  81. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  82. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  83. package/ciel_runtime_support/providers/__init__.py +6 -0
  84. package/ciel_runtime_support/providers/alibaba.py +634 -0
  85. package/ciel_runtime_support/providers/catalog.py +24 -16
  86. package/ciel_runtime_support/providers/deepseek.py +73 -0
  87. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  88. package/ciel_runtime_support/providers/kimi.py +69 -9
  89. package/ciel_runtime_support/providers/ollama.py +8 -0
  90. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  91. package/ciel_runtime_support/providers/vllm.py +7 -1
  92. package/ciel_runtime_support/response_collection.py +68 -18
  93. package/ciel_runtime_support/response_collection_context.py +391 -0
  94. package/ciel_runtime_support/response_stream_context.py +555 -0
  95. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  96. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  97. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  98. package/ciel_runtime_support/router_http.py +239 -3
  99. package/ciel_runtime_support/router_observability_context.py +251 -0
  100. package/ciel_runtime_support/router_process_context.py +200 -0
  101. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  102. package/ciel_runtime_support/router_request_assembly.py +399 -0
  103. package/ciel_runtime_support/router_request_context.py +215 -0
  104. package/ciel_runtime_support/router_server_context.py +82 -0
  105. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  106. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  107. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  108. package/ciel_runtime_support/runtime_constants.py +16 -1
  109. package/ciel_runtime_support/runtime_launch.py +9 -5
  110. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  111. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  112. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  113. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  114. package/ciel_runtime_support/runtime_paths.py +60 -40
  115. package/ciel_runtime_support/runtime_primitives.py +78 -0
  116. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  117. package/ciel_runtime_support/statusline_script.py +57 -8
  118. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  119. package/ciel_runtime_support/tool_schema.py +40 -2
  120. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  121. package/ciel_runtime_support/upstream_dump.py +68 -0
  122. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  123. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  124. package/docs/Configuration.md +50 -0
  125. package/docs/Test-Suite.md +1 -0
  126. 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
- ensure_probe_cache: Callable[..., Any]
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.ensure_probe_cache(
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 self.capability.cached_probe_servers()
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
- self.projection.public_name(str(name or "").strip())
172
- for name in channel_owned_server_names or []
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
- _CODEX_NON_OUTPUT_EVENT_TYPES = frozenset({"response.created", "response.in_progress"})
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 _codex_capacity_error_code(event: dict[str, Any]) -> str | None:
90
- if event.get("type") != "response.failed":
91
- return None
92
- response = event.get("response")
93
- if not isinstance(response, dict):
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
- code = str(error.get("code") or "").strip()
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
+ ]