@oneciel-ai/ciel-runtime 0.2.47 → 0.2.48

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 (30) hide show
  1. package/ciel_runtime.py +4 -4
  2. package/ciel_runtime_support/architecture.py +28 -0
  3. package/ciel_runtime_support/channel_terminal_proxy.py +6 -1
  4. package/ciel_runtime_support/codex_completion_gate.py +17 -3
  5. package/ciel_runtime_support/codex_turn_recovery.py +42 -6
  6. package/ciel_runtime_support/compatibility_test.py +3 -2
  7. package/ciel_runtime_support/config_migrations.py +44 -0
  8. package/ciel_runtime_support/provider_request_access.py +3 -4
  9. package/ciel_runtime_support/provider_responses_passthrough.py +167 -26
  10. package/ciel_runtime_support/providers/meta.py +5 -0
  11. package/ciel_runtime_support/providers/opencode.py +64 -1
  12. package/ciel_runtime_support/providers/opencode_catalog.py +8 -2
  13. package/ciel_runtime_support/providers/opencode_go.py +20 -2
  14. package/ciel_runtime_support/providers/openrouter.py +26 -2
  15. package/ciel_runtime_support/runtime_constants.py +1 -1
  16. package/ciel_runtime_support/windows_conpty.py +14 -0
  17. package/ciel_runtime_support/windows_console_guard.py +96 -0
  18. package/docs/journal/2026/09/16/diagnostics/opencode/go-session-header.okf +26 -0
  19. package/docs/journal/2026/09/16/fixes/opencode/kimi-k3-turn-recovery.okf +21 -0
  20. package/docs/journal/2026/09/16/providers/opencode/union-alpha.okf +34 -0
  21. package/docs/journal/2026/09/17/analysis/codebase/structure-survey.okf +22 -0
  22. package/docs/journal/2026/09/17/diagnostics/riskonnect/wsg-screenshot.okf +15 -0
  23. package/docs/journal/2026/09/17/diagnostics/yeti-01/duplicate-final-and-stall.okf +29 -0
  24. package/docs/journal/2026/09/17/fixes/codex/passthrough-compaction-completion-gate.okf +30 -0
  25. package/docs/journal/2026/09/17/providers/opencode/union-alpha-go-zen-vs-opencode-client.okf +53 -0
  26. package/docs/journal/2026/09/17/providers/openrouter/union-alpha-catalog.okf +29 -0
  27. package/docs/journal/2026/09/17/releases/codex/general-completion-gate-deployment.okf +24 -0
  28. package/docs/journal/2026/09/17/releases/codex/turn-gate-deployment.okf +27 -0
  29. package/docs/journal/2026/09/17/releases/local-nightly/deploy-bff7596.okf +28 -0
  30. package/package.json +1 -1
package/ciel_runtime.py CHANGED
@@ -1346,8 +1346,8 @@ _PROVIDER_REQUEST_ACCESS = ProviderRequestAccessService(
1346
1346
  request_policy=lambda provider, pcfg: provider_request_policy(provider, pcfg),
1347
1347
  select_api_key=lambda provider, pcfg: select_provider_api_key(provider, pcfg),
1348
1348
  meaningful_key=project_meaningful_key_value,
1349
- adapter_headers=lambda provider, pcfg, key: configured_provider_adapter(provider, pcfg).build_headers(
1350
- provider_contract_config(provider, pcfg), key),
1349
+ adapter_headers=lambda provider, pcfg, key, router_originated=False: configured_provider_adapter(provider, pcfg).request_headers(
1350
+ provider_contract_config(provider, pcfg), key, router_originated=router_originated),
1351
1351
  inbound_credentials=lambda key, inbound: credential.headers if (credential := resolve_anthropic_credentials(key, inbound)) is not None else None,
1352
1352
  ),
1353
1353
  effects=ProviderRequestAccessEffects(
@@ -2895,7 +2895,7 @@ def body_with_codex_compat_instructions(cfg: dict[str, Any], provider: str, pcfg
2895
2895
  body = codex_turn_recovery.body_with_codex_compat_instructions(body, ROUTED_CODEX_COMPAT_PROMPT, is_native_codex=codex_routed_enabled(provider, pcfg), compat_enabled=should_append_compat_prompt(provider, pcfg, cfg))
2896
2896
  return body_with_remote_memory_prompt(body, "openai_responses")
2897
2897
  def _codex_turn_recovery_services() -> codex_turn_recovery.CodexTurnRecoveryServices:
2898
- return codex_turn_recovery.CodexTurnRecoveryServices(should_retry=should_retry_preamble_only_turn, collect_message=collect_provider_message_for_responses, log=router_log, prepare_reasoning_budget_retry=lambda provider, pcfg, body: codex_turn_recovery.prepare_provider_reasoning_output_budget_retry(provider, pcfg, body, adapter_for=configured_provider_adapter, contract_for=provider_contract_config, resolve_model=resolve_requested_model, select_protocol=select_provider_protocol))
2898
+ return codex_turn_recovery.CodexTurnRecoveryServices(should_retry=should_retry_preamble_only_turn, collect_message=collect_provider_message_for_responses, log=router_log, is_plan_mode=plan_mode_active, prepare_reasoning_budget_retry=lambda provider, pcfg, body: codex_turn_recovery.prepare_provider_reasoning_output_budget_retry(provider, pcfg, body, adapter_for=configured_provider_adapter, contract_for=provider_contract_config, resolve_model=resolve_requested_model, select_protocol=select_provider_protocol))
2899
2899
  def recover_codex_preamble_only_turn(handler: Any, provider: str, pcfg: dict[str, Any], body: dict[str, Any], message: dict[str, Any]) -> dict[str, Any]:
2900
2900
  return codex_turn_recovery.recover_preamble_only_turn(handler, provider, pcfg, body, message, _codex_turn_recovery_services())
2901
2901
  def forward_anthropic_via_responses(handler: Any, provider: str, pcfg: dict[str, Any], body: dict[str, Any], model: str) -> None: AnthropicResponsesBridge(AnthropicResponsesBridgePorts(AnthropicResponsesProjectionPorts(anthropic_messages_to_openai_responses, openai_response_to_anthropic_message, provider_upstream_model, apply_provider_adapter_request_policy), AnthropicResponsesTransportPorts(provider_endpoint, provider_headers, post_json_with_rate_retry, open_provider_request_with_key_retry, provider_request_timeout_seconds), AnthropicResponsesOutputPorts(write_anthropic_message_response, write_json, ResponsesAnthropicStreamWriter(openai_response_to_anthropic_message).forward))).forward(handler, provider, pcfg, body, model)
@@ -3659,7 +3659,7 @@ def compatibility_test_services() -> CompatibilityTestServices:
3659
3659
  provider_native_compat_enabled, upstream_api_model_id, vllm_native_compat_enabled, vllm_tool_parser_hint),
3660
3660
  request=CompatibilityTestRequest(compatibility_endpoint_probe_lines, compatibility_failure_diagnosis, compatibility_http_error_message,
3661
3661
  post_json, provider_headers, provider_ip_family_probe_lines, run_compatibility_api_key_probes,
3662
- start_router_if_needed, stop_router_processes),
3662
+ start_router_if_needed, stop_router_processes, lambda p, c: configured_provider_adapter(p, c).compatibility_headers(provider_contract_config(p, c))),
3663
3663
  protocol=CompatibilityTestProtocol(compatibility_text_request, compatibility_tool_request, compatibility_tool_result_request,
3664
3664
  find_compat_tool_use, known_compatibility_tool_use_blocker, normalize_thinking_for_non_anthropic_provider,
3665
3665
  normalize_tool_choice_for_provider, ollama_chat_request, resolve_requested_model, response_text_preview),
@@ -334,6 +334,34 @@ class ProviderAdapter(ABC):
334
334
  def build_headers(self, config: ProviderConfig, api_key: str | None) -> Mapping[str, str]:
335
335
  """Build upstream HTTP headers for this provider."""
336
336
 
337
+ def compatibility_headers(self, config: ProviderConfig) -> Mapping[str, str]:
338
+ """Return headers scoped to one provider compatibility-test conversation."""
339
+
340
+ del config
341
+ return {}
342
+
343
+ def session_headers(self, config: ProviderConfig) -> Mapping[str, str]:
344
+ """Return identity headers for requests the router originates itself.
345
+
346
+ Client traffic forwards the client's own headers verbatim; advisor,
347
+ compaction and probe requests have none to forward.
348
+ """
349
+
350
+ del config
351
+ return {}
352
+
353
+ def request_headers(
354
+ self,
355
+ config: ProviderConfig,
356
+ api_key: str | None,
357
+ *,
358
+ router_originated: bool = False,
359
+ ) -> Mapping[str, str]:
360
+ headers = dict(self.build_headers(config, api_key))
361
+ if router_originated:
362
+ headers.update(self.session_headers(config))
363
+ return headers
364
+
337
365
  def parse_rate_limit(self, response_or_error: Any) -> RateLimitState | None:
338
366
  """Return a rate-limit observation when the provider exposes one."""
339
367
 
@@ -411,7 +411,12 @@ def run_windows_channel_terminal_proxy(
411
411
  input_ready=now >= channel_input_ready_at,
412
412
  )
413
413
  console.sleep(0.05)
414
- return proc.wait()
414
+ returncode = proc.wait()
415
+ policy.log("INFO", f"channel_windows_console_child_exit pid={proc.pid} returncode={returncode}")
416
+ return returncode
417
+ except BaseException as exc:
418
+ policy.log("ERROR", f"channel_windows_console_proxy_exception type={type(exc).__name__}")
419
+ raise
415
420
  finally:
416
421
  cleanup_resources()
417
422
 
@@ -134,16 +134,29 @@ class ResponsesCompletionObservation:
134
134
  )
135
135
 
136
136
 
137
+ def request_allows_completion_check(body: dict[str, Any]) -> bool:
138
+ """Return whether the client let the model act in this request at all."""
139
+
140
+ return bool(body.get("tools")) and body.get("tool_choice") != "none"
141
+
142
+
137
143
  def request_requires_completion_check(
138
144
  body: dict[str, Any], observation: ResponsesCompletionObservation
139
145
  ) -> bool:
140
- """Use response structure only; never classify natural-language wording."""
146
+ """Use response structure only; never classify natural-language wording.
147
+
148
+ Any text-only final is ambiguous while tools are available: observed turns
149
+ ended on a progress announcement after a tool result, straight after the
150
+ user's message, and after Codex's mid-turn compaction, with and without a
151
+ reasoning item, across unrelated providers and models. What came before
152
+ the reply therefore decides nothing; the model confirms through the
153
+ private tool or does the work.
154
+ """
141
155
 
142
156
  return bool(
143
- body.get("tools")
157
+ request_allows_completion_check(body)
144
158
  and observation.parseable
145
159
  and observation.status == "completed"
146
- and observation.has_reasoning
147
160
  and not observation.has_action
148
161
  and observation.visible_text.strip()
149
162
  and observation.output
@@ -194,5 +207,6 @@ def completion_check_body(
194
207
  __all__ = [
195
208
  "ResponsesCompletionObservation",
196
209
  "completion_check_body",
210
+ "request_allows_completion_check",
197
211
  "request_requires_completion_check",
198
212
  ]
@@ -324,24 +324,58 @@ def message_confirms_completion(message: dict[str, Any]) -> bool:
324
324
  def message_requires_completion_check(
325
325
  body: dict[str, Any], message: dict[str, Any]
326
326
  ) -> bool:
327
- """Gate a no-tool reasoning response without inspecting natural language."""
327
+ """Check a text-only final turn without inspecting natural language.
328
+
329
+ While tools are available a visible no-tool reply is ambiguous: it may be
330
+ the completed answer or merely a progress update. Observed turns ended on
331
+ such updates after tool results, straight after the user's message, and
332
+ after compaction, with and without reasoning blocks, on unrelated
333
+ providers. Let the model confirm through the private tool.
334
+ """
335
+
336
+ tool_choice = body.get("tool_choice")
337
+ tools_forbidden = (
338
+ tool_choice.get("type") if isinstance(tool_choice, dict) else tool_choice
339
+ ) == "none"
340
+ return bool(
341
+ body.get("tools")
342
+ and not tools_forbidden
343
+ and not message_has_tool_use(message)
344
+ and message_text(message).strip()
345
+ )
346
+
347
+
348
+ def _retry_still_unfinished(body: dict[str, Any], message: dict[str, Any]) -> bool:
349
+ """Bound Kimi's loop by structure: reasoning or a trailing tool result."""
350
+
351
+ latest_user = next(
352
+ (
353
+ item
354
+ for item in reversed(body.get("messages") or [])
355
+ if isinstance(item, dict) and item.get("role") == "user"
356
+ ),
357
+ {},
358
+ )
359
+ latest_content = latest_user.get("content") or []
360
+ follows_tool_result = isinstance(latest_content, list) and any(
361
+ isinstance(block, dict) and block.get("type") == "tool_result"
362
+ for block in latest_content
363
+ )
328
364
 
329
365
  return bool(
330
366
  body.get("tools")
331
- and message_has_reasoning(message)
367
+ and (message_has_reasoning(message) or follows_tool_result)
332
368
  and not message_has_tool_use(message)
333
369
  and message_text(message).strip()
334
370
  )
335
371
 
336
372
 
337
373
  def _is_kimi_turn(provider: str, body: dict[str, Any]) -> bool:
338
- """Identify Kimi across its native and Ollama Cloud provider routes."""
374
+ """Identify Kimi by model identity across provider transports."""
339
375
 
340
376
  provider_name = str(provider or "").strip().casefold()
341
377
  if provider_name == "kimi":
342
378
  return True
343
- if provider_name not in {"ollama", "ollama-cloud"}:
344
- return False
345
379
  model = str(body.get("model") or "").strip().casefold()
346
380
  return "kimi-k3" in model
347
381
 
@@ -484,6 +518,7 @@ class CodexTurnRecoveryServices:
484
518
  should_retry: Callable[[dict[str, Any], str, list[Any]], bool]
485
519
  collect_message: Callable[..., dict[str, Any]]
486
520
  log: Callable[[str, str], Any]
521
+ is_plan_mode: Callable[[dict[str, Any]], bool] | None = None
487
522
  prepare_reasoning_budget_retry: Callable[
488
523
  [str, dict[str, Any], dict[str, Any]],
489
524
  tuple[dict[str, Any], dict[str, Any], str],
@@ -523,6 +558,7 @@ def recover_preamble_only_turn(
523
558
  not empty_end_turn
524
559
  and not repeated_tool_guard
525
560
  and not reasoning_only
561
+ and not (services.is_plan_mode and services.is_plan_mode(body))
526
562
  and message_requires_completion_check(body, message)
527
563
  )
528
564
  if (
@@ -671,7 +707,7 @@ def recover_preamble_only_turn(
671
707
 
672
708
  retried_text = message_text(retried)
673
709
  retryable = services.should_retry(retry_body, retried_text, []) or (
674
- kimi_turn and message_requires_completion_check(retry_body, retried)
710
+ kimi_turn and _retry_still_unfinished(retry_body, retried)
675
711
  )
676
712
  services.log(
677
713
  "WARN" if retryable else "INFO",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  from dataclasses import dataclass
7
7
  import sys
8
- from typing import Any, Callable
8
+ from typing import Any, Callable, Mapping
9
9
  import urllib.error
10
10
 
11
11
 
@@ -57,6 +57,7 @@ class CompatibilityTestRequest:
57
57
  run_api_key_probes: Callable[..., Any]
58
58
  start_router: Callable[..., Any]
59
59
  stop_router: Callable[..., Any]
60
+ compatibility_headers: Callable[[str, dict[str, Any]], Mapping[str, str]]
60
61
 
61
62
 
62
63
  @dataclass(frozen=True, slots=True)
@@ -190,6 +191,7 @@ def run_compatibility_test(
190
191
  url = join_url(base, "/v1/messages")
191
192
  headers = provider_headers(provider, pcfg)
192
193
  headers[COMPATIBILITY_TEST_HEADER] = "1"
194
+ headers.update(request.compatibility_headers(provider, pcfg))
193
195
  if ollama_native:
194
196
  headers = {
195
197
  "content-type": "application/json",
@@ -367,4 +369,3 @@ def run_compatibility_test(
367
369
 
368
370
  set_compatibility_cache(cfg, provider, model, True, 200, "text/tool_use/tool_result OK", "")
369
371
  print("Compatibility: OK")
370
-
@@ -38,6 +38,50 @@ def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolic
38
38
  migrations = {}
39
39
  cfg["migrations"] = migrations
40
40
 
41
+ marker = "openrouter_union_alpha_catalog_20260917"
42
+ if not migrations.get(marker):
43
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
44
+ pcfg = providers.get("openrouter")
45
+ if isinstance(pcfg, dict):
46
+ custom = pcfg.get("custom_models")
47
+ if not isinstance(custom, list):
48
+ custom = []
49
+ pcfg["custom_models"] = custom
50
+ known = {
51
+ normalize_model_id("openrouter", str(model))
52
+ for model in custom
53
+ if str(model).strip()
54
+ }
55
+ if normalize_model_id("openrouter", "stealth/union-alpha") not in known:
56
+ custom.append("stealth/union-alpha")
57
+ migrations[marker] = True
58
+
59
+ marker = "openrouter_pareto_catalog_20260917"
60
+ if not migrations.get(marker):
61
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
62
+ pcfg = providers.get("openrouter")
63
+ if isinstance(pcfg, dict):
64
+ custom = pcfg.get("custom_models")
65
+ if not isinstance(custom, list):
66
+ custom = []
67
+ pcfg["custom_models"] = custom
68
+ # stealth/union-alpha ended its testing period (404 with a pointer
69
+ # to unbiased/pareto); replace it in existing catalogs.
70
+ custom[:] = [
71
+ model
72
+ for model in custom
73
+ if normalize_model_id("openrouter", str(model))
74
+ != normalize_model_id("openrouter", "stealth/union-alpha")
75
+ ]
76
+ known = {
77
+ normalize_model_id("openrouter", str(model))
78
+ for model in custom
79
+ if str(model).strip()
80
+ }
81
+ if normalize_model_id("openrouter", "unbiased/pareto") not in known:
82
+ custom.append("unbiased/pareto")
83
+ migrations[marker] = True
84
+
41
85
  marker = "meta_muse_spark_13_catalog_20260902"
42
86
  if not migrations.get(marker):
43
87
  providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
@@ -33,9 +33,8 @@ class ProviderRequestAccessPorts:
33
33
  ]
34
34
  select_api_key: Callable[[str, dict[str, Any]], str | None]
35
35
  meaningful_key: Callable[[str], bool]
36
- adapter_headers: Callable[
37
- [str, dict[str, Any], str | None], Mapping[str, str]
38
- ]
36
+ # Called as (provider, config, key, router_originated=<bool>).
37
+ adapter_headers: Callable[..., Mapping[str, str]]
39
38
  inbound_credentials: Callable[
40
39
  [str, Any | None], Mapping[str, str] | None
41
40
  ]
@@ -174,7 +173,7 @@ class ProviderRequestAccessService:
174
173
  else:
175
174
  headers.update(
176
175
  self.ports.adapter_headers(
177
- provider, config, meaningful
176
+ provider, config, meaningful, router_originated=not passthrough
178
177
  )
179
178
  )
180
179
  configured_protocol_headers = self._configured_protocol_headers(
@@ -11,6 +11,13 @@ from dataclasses import dataclass
11
11
  from http.client import IncompleteRead
12
12
  from typing import Any, Callable, Mapping
13
13
 
14
+ from .codex_completion_gate import (
15
+ ResponsesCompletionObservation,
16
+ completion_check_body,
17
+ request_allows_completion_check,
18
+ request_requires_completion_check,
19
+ )
20
+ from .codex_turn_recovery import CODEX_COMPLETION_TOOL_NAME
14
21
  from .responses_usage_observer import ResponsesUsageObserver
15
22
  from .responses_cache_diagnostics import (
16
23
  cache_trace,
@@ -59,6 +66,13 @@ class ProviderResponsesPassthroughPorts:
59
66
  endpoint: Callable[[str, dict[str, Any], str], str] | None = None
60
67
 
61
68
 
69
+ @dataclass(frozen=True, slots=True)
70
+ class _BufferedResponsesStream:
71
+ status: int
72
+ headers: Any
73
+ stream: Any
74
+
75
+
62
76
  class ProviderResponsesPassthrough:
63
77
  """Forward Responses without collapsing typed items into another protocol."""
64
78
 
@@ -286,8 +300,14 @@ class ProviderResponsesPassthrough:
286
300
  upstream_body: dict[str, Any],
287
301
  response_tools: Mapping[str, Mapping[str, Any]],
288
302
  cache_profile: Mapping[str, Any],
303
+ *,
304
+ completion_gate_headers: Mapping[str, str] | None = None,
289
305
  ) -> None:
290
- """Validate a native Responses stream before exposing it downstream."""
306
+ """Validate a native Responses stream before exposing it downstream.
307
+
308
+ ``completion_gate_headers`` enables the completion check and carries the
309
+ request headers as built, not as urllib amended them while sending.
310
+ """
291
311
 
292
312
  retries = self._stream_truncation_retries(config)
293
313
  max_attempts = retries + 1
@@ -300,22 +320,26 @@ class ProviderResponsesPassthrough:
300
320
  pcfg=config,
301
321
  ) as response, tempfile.SpooledTemporaryFile(max_size=4 * 1024 * 1024) as spool:
302
322
  usage = ResponsesUsageObserver()
323
+ completion = ResponsesCompletionObservation()
303
324
  received_bytes = 0
304
325
  read_error: BaseException | None = None
305
326
  try:
306
327
  while chunk := response.read(65_536):
307
328
  received_bytes += len(chunk)
308
329
  usage.feed(chunk)
330
+ completion.feed(chunk)
309
331
  spool.write(chunk)
310
332
  except (IncompleteRead, OSError) as exc:
311
333
  partial = bytes(getattr(exc, "partial", b"") or b"")
312
334
  if partial:
313
335
  received_bytes += len(partial)
314
336
  usage.feed(partial)
337
+ completion.feed(partial)
315
338
  spool.write(partial)
316
339
  read_error = exc
317
340
 
318
341
  observed = usage.finish()
342
+ completion.finish()
319
343
  stream_expected = bool(upstream_body.get("stream", True))
320
344
  terminal_missing = stream_expected and usage.terminal_event is None
321
345
  if read_error is not None and not terminal_missing:
@@ -354,36 +378,143 @@ class ProviderResponsesPassthrough:
354
378
  received_bytes=received_bytes,
355
379
  ) from failure
356
380
 
357
- handler.send_response(getattr(response, "status", 200))
358
- self._ports.copy_response_headers(
359
- handler,
360
- self._response_headers(
361
- response.headers, transformed=bool(response_tools)
362
- ),
363
- )
364
- handler.end_headers()
365
- spool.seek(0)
366
- projector = (
367
- ResponsesCustomToolStreamProjector(response_tools)
368
- if response_tools
369
- else None
370
- )
371
- while chunk := spool.read(65_536):
372
- output = projector.feed(chunk) if projector is not None else chunk
373
- if output:
374
- handler.wfile.write(output)
375
- handler.wfile.flush()
376
- if projector is not None:
377
- tail = projector.finish()
378
- if tail:
379
- handler.wfile.write(tail)
380
- handler.wfile.flush()
381
381
  if observed:
382
382
  observation = usage_with_cache_profile(observed, cache_profile)
383
383
  self._ports.record_usage(provider, model, observation)
384
384
  self._ports.log(*cache_trace(provider, model, observation))
385
+ continuation = (
386
+ self._completion_continuation(
387
+ request.full_url,
388
+ completion_gate_headers,
389
+ provider,
390
+ config,
391
+ upstream_body,
392
+ completion,
393
+ )
394
+ if completion_gate_headers is not None
395
+ else None
396
+ )
397
+ try:
398
+ chosen = continuation or _BufferedResponsesStream(
399
+ int(getattr(response, "status", 200)), response.headers, spool
400
+ )
401
+ handler.send_response(chosen.status)
402
+ self._ports.copy_response_headers(
403
+ handler,
404
+ self._response_headers(
405
+ chosen.headers, transformed=bool(response_tools)
406
+ ),
407
+ )
408
+ handler.end_headers()
409
+ chosen.stream.seek(0)
410
+ projector = (
411
+ ResponsesCustomToolStreamProjector(response_tools)
412
+ if response_tools
413
+ else None
414
+ )
415
+ while chunk := chosen.stream.read(65_536):
416
+ output = (
417
+ projector.feed(chunk) if projector is not None else chunk
418
+ )
419
+ if output:
420
+ handler.wfile.write(output)
421
+ handler.wfile.flush()
422
+ if projector is not None:
423
+ tail = projector.finish()
424
+ if tail:
425
+ handler.wfile.write(tail)
426
+ handler.wfile.flush()
427
+ finally:
428
+ if continuation is not None:
429
+ continuation.stream.close()
385
430
  return
386
431
 
432
+ def _completion_continuation(
433
+ self,
434
+ url: str,
435
+ request_headers: Mapping[str, str],
436
+ provider: str,
437
+ config: dict[str, Any],
438
+ upstream_body: dict[str, Any],
439
+ completion: ResponsesCompletionObservation,
440
+ ) -> _BufferedResponsesStream | None:
441
+ """Return the model's next action when a text-only final was unconfirmed.
442
+
443
+ The candidate response stays authoritative unless the follow-up is a
444
+ completed response that performs real work; every failure keeps it.
445
+ """
446
+
447
+ if not request_requires_completion_check(upstream_body, completion):
448
+ return None
449
+ model = str(upstream_body.get("model") or "")
450
+ # The candidate's output items are replayed as input, exactly as Codex
451
+ # would replay them on its next request, so they need the same repair,
452
+ # and the forced tool choice must pass the provider's own request rules.
453
+ data = self._encode(
454
+ dict(
455
+ self._ports.normalize_request(
456
+ provider,
457
+ config,
458
+ repair_replayed_response_items(
459
+ completion_check_body(upstream_body, completion)
460
+ ),
461
+ )
462
+ )
463
+ )
464
+ headers = dict(request_headers)
465
+ dump_upstream_request(url, data, self._ports.log, headers=headers)
466
+ follow_up = urllib.request.Request(
467
+ url, data=data, headers=headers, method="POST"
468
+ )
469
+ stream = tempfile.SpooledTemporaryFile(max_size=4 * 1024 * 1024)
470
+ try:
471
+ with self._ports.urlopen(
472
+ follow_up,
473
+ timeout=self._ports.timeout_seconds(config),
474
+ provider=provider,
475
+ pcfg=config,
476
+ ) as response:
477
+ usage = ResponsesUsageObserver()
478
+ validated = ResponsesCompletionObservation()
479
+ while chunk := response.read(65_536):
480
+ usage.feed(chunk)
481
+ validated.feed(chunk)
482
+ stream.write(chunk)
483
+ observed = usage.finish()
484
+ validated.finish()
485
+ if observed:
486
+ self._ports.record_usage(provider, model, observed)
487
+ status = int(getattr(response, "status", 200))
488
+ response_headers = response.headers
489
+ except Exception as exc: # noqa: BLE001 - the candidate response must survive
490
+ stream.close()
491
+ self._ports.log(
492
+ "WARN",
493
+ "provider_responses_completion_gate_failed "
494
+ f"provider={provider} model={model} error={type(exc).__name__}: {exc}",
495
+ )
496
+ return None
497
+ # A follow-up that names the private tool can never reach the client:
498
+ # Codex does not know it and would answer with an unknown-tool error.
499
+ calls_private_tool = any(
500
+ item.get("name") == CODEX_COMPLETION_TOOL_NAME for item in validated.output
501
+ )
502
+ if validated.status == "completed" and validated.has_action and not calls_private_tool:
503
+ self._ports.log(
504
+ "WARN",
505
+ "provider_responses_completion_gate_continued "
506
+ f"provider={provider} model={model}",
507
+ )
508
+ return _BufferedResponsesStream(status, response_headers, stream)
509
+ stream.close()
510
+ self._ports.log(
511
+ "INFO",
512
+ "provider_responses_completion_gate_kept "
513
+ f"provider={provider} model={model} "
514
+ f"confirmed={validated.completion_confirmed} status={validated.status or '-'}",
515
+ )
516
+ return None
517
+
387
518
  def forward(
388
519
  self,
389
520
  handler: Any,
@@ -435,7 +566,16 @@ class ProviderResponsesPassthrough:
435
566
  headers=request_headers,
436
567
  method="POST",
437
568
  )
438
- if not remote_bridge and self._stream_truncation_retries(config):
569
+ # A response that may still need the completion check is held back, as
570
+ # on the Codex backend route; requests without usable tools keep streaming.
571
+ completion_gate = (
572
+ not remote_bridge
573
+ and bool(upstream_body.get("stream", True))
574
+ and request_allows_completion_check(upstream_body)
575
+ )
576
+ if not remote_bridge and (
577
+ completion_gate or self._stream_truncation_retries(config)
578
+ ):
439
579
  self._forward_buffered_stream(
440
580
  handler,
441
581
  request,
@@ -444,6 +584,7 @@ class ProviderResponsesPassthrough:
444
584
  upstream_body,
445
585
  response_tools,
446
586
  cache_profile,
587
+ completion_gate_headers=request_headers if completion_gate else None,
447
588
  )
448
589
  return delivery_body
449
590
  with self._ports.urlopen(
@@ -295,6 +295,11 @@ class MetaModelProviderAdapter(HttpBearerProviderAdapter):
295
295
  tools = request.get("tools")
296
296
  if isinstance(tools, list):
297
297
  request["tools"] = [cls._normalize_responses_tool(tool) for tool in tools]
298
+ # Meta Responses answers 400 "only "auto" is supported for tool_choice"
299
+ # to "required" and to named function choices; keep the tools usable.
300
+ tool_choice = request.get("tool_choice")
301
+ if tool_choice == "required" or isinstance(tool_choice, Mapping):
302
+ request["tool_choice"] = "auto"
298
303
  raw_input = request.get("input")
299
304
  if isinstance(raw_input, list):
300
305
  request["input"] = [cls._normalize_responses_input(item) for item in raw_input]