@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.
- package/ciel_runtime.py +4 -4
- package/ciel_runtime_support/architecture.py +28 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +6 -1
- package/ciel_runtime_support/codex_completion_gate.py +17 -3
- package/ciel_runtime_support/codex_turn_recovery.py +42 -6
- package/ciel_runtime_support/compatibility_test.py +3 -2
- package/ciel_runtime_support/config_migrations.py +44 -0
- package/ciel_runtime_support/provider_request_access.py +3 -4
- package/ciel_runtime_support/provider_responses_passthrough.py +167 -26
- package/ciel_runtime_support/providers/meta.py +5 -0
- package/ciel_runtime_support/providers/opencode.py +64 -1
- package/ciel_runtime_support/providers/opencode_catalog.py +8 -2
- package/ciel_runtime_support/providers/opencode_go.py +20 -2
- package/ciel_runtime_support/providers/openrouter.py +26 -2
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/windows_conpty.py +14 -0
- package/ciel_runtime_support/windows_console_guard.py +96 -0
- package/docs/journal/2026/09/16/diagnostics/opencode/go-session-header.okf +26 -0
- package/docs/journal/2026/09/16/fixes/opencode/kimi-k3-turn-recovery.okf +21 -0
- package/docs/journal/2026/09/16/providers/opencode/union-alpha.okf +34 -0
- package/docs/journal/2026/09/17/analysis/codebase/structure-survey.okf +22 -0
- package/docs/journal/2026/09/17/diagnostics/riskonnect/wsg-screenshot.okf +15 -0
- package/docs/journal/2026/09/17/diagnostics/yeti-01/duplicate-final-and-stall.okf +29 -0
- package/docs/journal/2026/09/17/fixes/codex/passthrough-compaction-completion-gate.okf +30 -0
- package/docs/journal/2026/09/17/providers/opencode/union-alpha-go-zen-vs-opencode-client.okf +53 -0
- package/docs/journal/2026/09/17/providers/openrouter/union-alpha-catalog.okf +29 -0
- package/docs/journal/2026/09/17/releases/codex/general-completion-gate-deployment.okf +24 -0
- package/docs/journal/2026/09/17/releases/codex/turn-gate-deployment.okf +27 -0
- package/docs/journal/2026/09/17/releases/local-nightly/deploy-bff7596.okf +28 -0
- 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).
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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]
|