Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,30 @@ chromedriver` for the browser pass — works as before.)*
See [generic typed decision/data flows](docs/TYPED-FLOWS.md) for classification, selection and conditional generation.

See [decision routing within generative flows](docs/DECISION-FLOWS.md) to select an economical or capable generation policy from conversation and tool history.

### Provider-exposed reasoning in buffered chat

OpenAI-compatible adapters retain the upstream assistant message (including
`reasoning`, `reasoning_content` and `reasoning_details`) and usage details through
the public buffered chat response. A reasoning-only response is returned with
its original finish reason, so callers can diagnose an exhausted output budget
instead of losing the result to an empty-content fallback. The stream-backed
buffered adapter also retains exposed reasoning.
Empty or whitespace-only reasoning and detail metadata without a payload do not
count as output: they retain empty-response fallback and first-output deadlines.

The existing Codex OAuth backend forwards explicit `reasoning.effort` and
`reasoning.summary` controls (or `reasoning_effort`); when effort is specified,
summary defaults to `auto`. It preserves returned Responses reasoning items as
`x_reasoning_items` on chat responses, and exposes readable summaries as typed
`message.reasoning_details`. Encrypted items remain opaque. Responses output
also retains the original reasoning items. Its buffered SSE replay emits each
reasoning item and summary before subsequent text or tool items, with contiguous
output indexes. These fields describe only data the
provider returned, never undisclosed internal chain-of-thought.

This does not introduce a new OAuth login path, enable reasoning by default for
all routes, or deploy the router. The Codex endpoint still has its existing
parameter restrictions. The host application remains responsible for private
trace persistence and spend controls. No provider credentials are included in
these returned artifacts.
36 changes: 34 additions & 2 deletions codex_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ def build_codex_body(request: dict) -> dict:
tc = _to_responses_tool_choice(request.get("tool_choice"))
if tc is not None:
body["tool_choice"] = tc
controls = request.get("reasoning") or {}
reasoning = {k: controls[k] for k in ("effort", "summary") if k in controls}
if request.get("reasoning_effort") is not None:
reasoning.setdefault("effort", request["reasoning_effort"])
if reasoning:
reasoning.setdefault("summary", "auto")
body["reasoning"] = reasoning
# The ChatGPT-account Codex endpoint rejects some public Responses API
# params even though they are accepted elsewhere. Do not forward max_tokens
# as max_output_tokens, and do not forward temperature; live endpoint errors
Expand Down Expand Up @@ -213,6 +220,8 @@ def aggregate_codex_sse(lines: Iterable[str], latency_ms: int) -> dict:
# function_call items keyed by their streaming item id, in arrival order.
fcalls: dict = {}
fcorder: list = []
reasoning_items: dict = {}
summaries: dict = {}

for line in lines:
line = line.strip()
Expand All @@ -229,6 +238,12 @@ def aggregate_codex_sse(lines: Iterable[str], latency_ms: int) -> dict:
if etype == "response.output_text.delta":
if ev.get("delta"):
text_parts.append(ev["delta"])
elif etype == "response.reasoning_summary_text.delta":
key = (ev.get("item_id", "reasoning"), ev.get("summary_index", 0))
summaries.setdefault(key, []).append(ev.get("delta") or "")
elif etype == "response.output_item.done" and (ev.get("item") or {}).get("type") == "reasoning":
item = ev["item"]
reasoning_items[item.get("id", "reasoning")] = item
elif etype == "response.output_item.added":
item = ev.get("item") or {}
if item.get("type") == "function_call":
Expand All @@ -248,9 +263,12 @@ def aggregate_codex_sse(lines: Iterable[str], latency_ms: int) -> dict:
iid = ev.get("item_id")
if iid in fcalls and ev.get("arguments") is not None:
fcalls[iid]["done"] = ev["arguments"]
elif etype == "response.completed":
elif etype in ("response.completed", "response.incomplete"):
resp = ev.get("response") or {}
usage = resp.get("usage") or usage
for item in resp.get("output") or []:
if item.get("type") == "reasoning":
reasoning_items[item.get("id", "reasoning")] = item
if resp.get("status") == "incomplete":
finish_reason = "length"
elif etype in ("response.failed", "error"):
Expand All @@ -260,6 +278,14 @@ def aggregate_codex_sse(lines: Iterable[str], latency_ms: int) -> dict:
if err is not None:
return err

for (iid, index), parts in summaries.items():
if iid not in reasoning_items:
reasoning_items[iid] = {"id": iid, "type": "reasoning", "summary": []}
if not any(p.get("text") == "".join(parts) for p in reasoning_items[iid].get("summary", [])):
# Final items are authoritative; delta-only streams remain explicitly partial.
if not reasoning_items[iid].get("summary") or reasoning_items[iid].get("partial"):
reasoning_items[iid].setdefault("summary", []).append({"type": "summary_text", "text": "".join(parts)})
reasoning_items[iid]["partial"] = True
tool_calls: "list[dict] | None" = None
if fcorder:
tool_calls = []
Expand All @@ -286,6 +312,12 @@ def aggregate_codex_sse(lines: Iterable[str], latency_ms: int) -> dict:
"tokens_cached": _cached_tokens(usage),
"cost_reported": usage.get("cost"),
"raw_model": None,
"reasoning_items": list(reasoning_items.values()),
"provider_message": {"reasoning_details": [
{"type": "reasoning.summary", "summary": part.get("text", ""), "source": "codex"}
for item in reasoning_items.values() for part in item.get("summary", [])
]} if reasoning_items else {},
"tokens_reasoning": (usage.get("output_tokens_details") or {}).get("reasoning_tokens"),
},
}

Expand All @@ -301,7 +333,7 @@ def _codex_line_has_output_delta(line: str) -> bool:
ev = json.loads(payload)
except ValueError:
return False
return ev.get("type") == "response.output_text.delta" and bool(ev.get("delta"))
return ev.get("type") in ("response.output_text.delta", "response.reasoning_summary_text.delta") and bool(ev.get("delta"))


def make_codex_async_call_provider(
Expand Down
33 changes: 31 additions & 2 deletions provider_adapters/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
Emit = Callable[[str], Awaitable[None]]


def _has_reasoning_content(message: dict) -> bool:
"""Empty deltas and detail metadata alone are not generated output."""
def nonblank(value):
return isinstance(value, str) and bool(value.strip())

if any(nonblank(message.get(key)) for key in ("reasoning", "reasoning_content")):
return True
details = message.get("reasoning_details")
return isinstance(details, list) and any(
isinstance(part, dict) and any(nonblank(part.get(key))
for key in ("text", "summary", "data", "encrypted_content"))
for part in details)


def _resolve_auth_headers(
request: dict,
env_get: Callable[[str], str | None],
Expand Down Expand Up @@ -571,6 +585,8 @@ async def _stream_openai_compatible_impl(

emitted = False
text_parts: list[str] = []
reasoning_parts: dict[str, list[str]] = {}
reasoning_details: list[dict] = []
tool_calls_acc: dict[int, dict] = {}
finish_reason = None
usage: dict = {}
Expand Down Expand Up @@ -633,6 +649,13 @@ def _timeout_err() -> dict:
delta = choice.get("delta") or {}
if choice.get("finish_reason"):
finish_reason = choice["finish_reason"]
for key in ("reasoning", "reasoning_content"):
if isinstance(delta.get(key), str):
reasoning_parts.setdefault(key, []).append(delta[key])
if isinstance(delta.get("reasoning_details"), list):
reasoning_details.extend(delta["reasoning_details"])
if _has_reasoning_content(delta):
saw_output = True
content = delta.get("content")
if content:
saw_output = True
Expand Down Expand Up @@ -661,7 +684,9 @@ def _timeout_err() -> dict:

tool_calls = [tool_calls_acc[i] for i in sorted(tool_calls_acc)] or None
text = "".join(text_parts)
if not text.strip() and not tool_calls:
provider_message = {**{k: "".join(v) for k, v in reasoning_parts.items()},
**({"reasoning_details": reasoning_details} if reasoning_details else {})}
if not text.strip() and not tool_calls and not _has_reasoning_content(provider_message):
return _err("bad_response", 200, _latency(), "empty assistant content")
return {
"ok": True,
Expand All @@ -676,6 +701,8 @@ def _timeout_err() -> dict:
"tokens_cached": _cached_tokens(usage),
"cost_reported": usage.get("cost"),
"raw_model": raw_model,
"provider_message": provider_message,
"provider_usage": usage,
"upstream": {k: _timing.data[k] for k in ("upstream_id", "upstream_provider", "tokens_reasoning") if k in _timing.data},
"tokens_reasoning": _timing.data.get("tokens_reasoning"),
},
Expand Down Expand Up @@ -716,7 +743,7 @@ def _parse_openai_response(
usage = data.get("usage") or {}
text = msg.get("content") or ""
tool_calls = msg.get("tool_calls")
if not str(text).strip() and not tool_calls:
if not str(text).strip() and not tool_calls and not _has_reasoning_content(msg):
return _err("bad_response", status, latency, "empty assistant content")
return {
"ok": True,
Expand All @@ -731,6 +758,8 @@ def _parse_openai_response(
"tokens_cached": _cached_tokens(usage),
"cost_reported": usage.get("cost"),
"raw_model": data.get("model"),
"provider_message": msg,
"provider_usage": usage,
"upstream": upstream_metadata(data),
"tokens_reasoning": upstream_metadata(data).get("tokens_reasoning"),
},
Expand Down
24 changes: 22 additions & 2 deletions responses_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def result_to_responses_object(
or requested_model or "")
ts = created_at if created_at is not None else int((now or time.time)())

output: list[dict] = []
output: list[dict] = list(resp.get("reasoning_items") or [])
if text:
output.append({
"type": "message",
Expand Down Expand Up @@ -239,6 +239,8 @@ def result_to_responses_object(
# cache reads correctly (mirrors the chat path's usage.prompt_tokens_details).
if resp.get("tokens_cached"):
usage["input_tokens_details"] = {"cached_tokens": resp["tokens_cached"]}
if resp.get("tokens_reasoning") is not None:
usage["output_tokens_details"] = {"reasoning_tokens": resp["tokens_reasoning"]}
if usage:
obj["usage"] = usage
return obj
Expand Down Expand Up @@ -281,7 +283,25 @@ def _emit(event: str, data: dict) -> str:
return frame

for out_index, item in enumerate(obj.get("output") or []):
if item.get("type") == "message":
if item.get("type") == "reasoning":
yield _emit("response.output_item.added",
{"output_index": out_index, "item": {**item, "summary": []}})
for summary_index, part in enumerate(item.get("summary") or []):
position = {"item_id": item["id"], "output_index": out_index,
"summary_index": summary_index}
text = part.get("text", "")
yield _emit("response.reasoning_summary_part.added",
{**position, "part": {**part, "text": ""}})
if text:
yield _emit("response.reasoning_summary_text.delta",
{**position, "delta": text})
yield _emit("response.reasoning_summary_text.done",
{**position, "text": text})
yield _emit("response.reasoning_summary_part.done",
{**position, "part": part})
yield _emit("response.output_item.done",
{"output_index": out_index, "item": item})
elif item.get("type") == "message":
text = (item.get("content") or [{}])[0].get("text", "")
yield _emit("response.output_item.added",
{"output_index": out_index, "item": {**item, "content": []}})
Expand Down
12 changes: 8 additions & 4 deletions shim.py
Original file line number Diff line number Diff line change
Expand Up @@ -1892,7 +1892,7 @@ def _openai_usage(response: dict) -> dict:
shared by the unary chat body, the streaming final chunk and /v1/compact.
Empty dict when the provider reported no token counts (caller omits the
key, per the additive wire contract)."""
usage: dict = {}
usage: dict = dict(response.get("provider_usage") or {})
for src_key, dst_key in (("tokens_in", "prompt_tokens"),
("tokens_out", "completion_tokens"),
("tokens_total", "total_tokens")):
Expand All @@ -1903,9 +1903,9 @@ def _openai_usage(response: dict) -> dict:
# explicit tokens_cached: 0 means caching was evaluated with no hits, and
# x_router already passes that 0 through — the OpenAI block must agree.
if response.get("tokens_cached") is not None:
usage["prompt_tokens_details"] = {"cached_tokens": response["tokens_cached"]}
usage["prompt_tokens_details"] = {**(usage.get("prompt_tokens_details") or {}), "cached_tokens": response["tokens_cached"]}
if response.get("tokens_reasoning") is not None:
usage["completion_tokens_details"] = {"reasoning_tokens": response["tokens_reasoning"]}
usage["completion_tokens_details"] = {**(usage.get("completion_tokens_details") or {}), "reasoning_tokens": response["tokens_reasoning"]}
return usage


Expand Down Expand Up @@ -1971,7 +1971,8 @@ def _router_response_to_openai(result: dict, requested_model: str,
response = result.get("response") or {}
chosen = result.get("chosen") or {}

message: dict = {"role": "assistant", "content": response.get("text") or ""}
message: dict = dict(response.get("provider_message") or {})
message.update(role="assistant", content=response.get("text") or "")
if response.get("tool_calls"):
message["tool_calls"] = response["tool_calls"]

Expand All @@ -1992,6 +1993,9 @@ def _router_response_to_openai(result: dict, requested_model: str,
}],
}

if response.get("reasoning_items"):
out["x_reasoning_items"] = response["reasoning_items"]

usage = _openai_usage(response)
if usage:
out["usage"] = usage
Expand Down
10 changes: 7 additions & 3 deletions tests/test_provider_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async def serve(reader, writer):


@pytest.mark.asyncio
async def test_buffered_preserves_metadata_without_reasoning_text_or_credentials():
async def test_buffered_preserves_reasoning_only_in_response_not_diagnostics():
async def handler(request):
assert json.loads(request.content)["reasoning_effort"] == "low"
return httpx.Response(200, json={
Expand All @@ -87,7 +87,9 @@ async def handler(request):
assert d["upstream_id"] == "gen-for-support"
assert d["upstream_provider"] == "Example Provider"
assert d["tokens_reasoning"] == 7
assert "PRIVATE REASONING" not in json.dumps(result)
assert result["response"]["provider_message"]["reasoning"] == "PRIVATE REASONING"
assert "PRIVATE REASONING" not in json.dumps(d)
assert "PRIVATE REASONING" not in json.dumps(routing_summary({"provider_diagnostics": [d]}))
usage = _openai_usage(result["response"])
assert usage["completion_tokens"] == 9
assert usage["completion_tokens_details"] == {"reasoning_tokens": 7}
Expand All @@ -114,7 +116,9 @@ async def emit(text):
assert d["first_reasoning_ms"] <= d["first_output_ms"] <= d["elapsed_ms"]
assert d["upstream_id"] == "gen-stream"
assert d["tokens_reasoning"] == 0
assert "PRIVATE REASONING" not in json.dumps(result)
assert result["response"]["provider_message"]["reasoning"] == "PRIVATE REASONING"
assert "PRIVATE REASONING" not in json.dumps(d)
assert "PRIVATE REASONING" not in json.dumps(routing_summary({"provider_diagnostics": [d]}))


def test_ledger_diagnostics_are_bounded_and_allowlisted():
Expand Down
Loading
Loading