Skip to content
Closed
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
56 changes: 49 additions & 7 deletions backend/app/codex_sdk_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,11 +593,18 @@ def _sdk_imports() -> dict[str, Any]:
ReasoningSummaryTextDeltaNotification,
ReasoningTextDeltaNotification,
ThreadTokenUsageUpdatedNotification,
ThreadTokenUsage,
TokenUsageBreakdown,
TurnCompletedNotification,
TurnStatus,
WebSearchThreadItem,
)

_enable_cache_write_usage_passthrough(
breakdown_type=TokenUsageBreakdown,
thread_usage_type=ThreadTokenUsage,
notification_type=ThreadTokenUsageUpdatedNotification,
)
# Multi-agent (collab) types exist only on multi-agent-capable SDKs (the
# openai-codex multi_agent_v2 line). Import them defensively in their own
# block so an SDK that predates them still boots — a missing type here must
Expand Down Expand Up @@ -688,6 +695,28 @@ def _sdk_imports() -> dict[str, Any]:
}


def _enable_cache_write_usage_passthrough(
*,
breakdown_type: Any,
thread_usage_type: Any,
notification_type: Any,
) -> None:
"""Preserve Codex's cache-write counter across generated SDK schema lag."""
field = "cache_write_input_tokens"
if field in getattr(breakdown_type, "model_fields", {}):
return
if getattr(breakdown_type, "model_config", {}).get("extra") == "allow":
return
from pydantic import ConfigDict

config = dict(getattr(breakdown_type, "model_config", {}))
config["extra"] = "allow"
breakdown_type.model_config = ConfigDict(**config)
breakdown_type.model_rebuild(force=True)
thread_usage_type.model_rebuild(force=True)
notification_type.model_rebuild(force=True)


def _extract_rate_limit_reset(snapshot) -> tuple[int | None, bool]:
"""Pull a park-worthy reset epoch + reached flag from a RateLimitSnapshot.

Expand Down Expand Up @@ -1931,12 +1960,10 @@ async def run_codex_sdk_turn(
)
model = DEFAULT_MODELS["codex"]

# Reasoning effort — Codex's `ReasoningEffort` enum accepts
# none/minimal/low/medium/high/xhigh; the Möbius picker exposes the
# last four. Pass through the string and let the SDK convert; if the
# value is unknown (e.g. a future picker addition the SDK doesn't
# yet accept), surface the SDK's error rather than silently dropping
# the choice.
# Reasoning effort comes from Codex's live per-model catalog. The generated
# enum implements `_missing_`, so newer wire values such as max/ultra survive
# even before codegen grows named members. Pass through the string and let
# the SDK convert; a genuinely invalid value degrades to the model default.
effort_str = agent_settings.get("effort")
effort = None
if effort_str:
Expand Down Expand Up @@ -2006,6 +2033,7 @@ async def run_codex_sdk_turn(
completed_message_phases: list[str | None] = []
first_token_usage: Any | None = None
final_token_usage: Any | None = None
call_token_usages: list[Any] = []
process_group_id: int | None = None
codex_context = sdk["AsyncCodex"](config=config)
process_group_capture_stop: asyncio.Event | None = None
Expand Down Expand Up @@ -2043,7 +2071,11 @@ def with_usage(result: RunnerResult) -> RunnerResult:
"""Attaches whatever the turn spent before it ended, however it ended."""
if final_token_usage is not None:
result["usage"] = _model_dump(final_token_usage)
metrics = normalize_codex_usage(first_token_usage, final_token_usage)
metrics = normalize_codex_usage(
first_token_usage,
final_token_usage,
call_token_usages,
)
result["usage_metrics"] = metrics
# Codex reports tokens but no dollar cost; derive it from the rate card so
# a Codex chat records real spend like a Claude chat instead of always
Expand Down Expand Up @@ -2380,6 +2412,16 @@ def aborted_result() -> RunnerResult:
if first_token_usage is None:
first_token_usage = payload.token_usage
final_token_usage = payload.token_usage
# ``last`` is the exact upstream Responses completion. Attribute only
# notifications owned by this turn so a resume-time replay from an
# earlier turn cannot be charged again.
if (
str(getattr(payload, "turn_id", ""))
== str(getattr(turn, "id", ""))
):
call_token_usages.append(
getattr(payload.token_usage, "last", None)
)
continue

if isinstance(
Expand Down
15 changes: 13 additions & 2 deletions backend/app/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,8 +708,9 @@ class CodexProvider(BaseProvider):
"""OpenAI Codex provider.

Live chat runs through the Codex Agent SDK (`codex_sdk_runner`); this
class shapes identity, auth, and the subprocess env (`CODEX_HOME` plus
the per-chat agent-browser session).
class shapes identity, auth, and the subprocess env (`CODEX_HOME`, optional
connected Claude credentials for reverse delegation, plus the per-chat
agent-browser session).
"""

name = "Codex"
Expand All @@ -733,6 +734,16 @@ def build_env(
) -> dict[str, str]:
env = dict(base_env)
env["CODEX_HOME"] = str(Path(data_dir) / "cli-auth" / "codex")
# Symmetric with ClaudeProvider's CODEX_HOME handoff: when Claude is
# connected, a Codex turn that deliberately delegates through
# `claude -p ...` inherits the right credential directory instead of
# falling back to an empty default. The provider skill/prompt owns when to
# delegate; this layer owns making the authenticated subprocess possible.
claude_creds = (
Path(data_dir) / "cli-auth" / "claude" / ".credentials.json"
)
if claude_creds.exists():
env["CLAUDE_CONFIG_DIR"] = str(claude_creds.parent)
# Match Claude's per-chat agent-browser isolation. Without this, Codex
# turns that invoke `agent-browser` all attach to the CLI's global
# "default" session; a browser launched by one Codex chat can then leak
Expand Down
86 changes: 77 additions & 9 deletions backend/app/usage_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def normalize_claude_usage(
_CODEX_FIELDS = (
"input_tokens",
"cached_input_tokens",
"cache_write_input_tokens",
"output_tokens",
"reasoning_output_tokens",
"total_tokens",
Expand All @@ -95,11 +96,17 @@ def _codex_breakdown(value: Any) -> dict[str, int]:
if value is None:
return {field: 0 for field in _CODEX_FIELDS}
def read(field: str) -> Any:
if not isinstance(value, dict):
return getattr(value, field, None)
camel = field.split("_")[0] + "".join(
part.title() for part in field.split("_")[1:]
)
if not isinstance(value, dict):
direct = getattr(value, field, None)
if direct is not None:
return direct
extra = getattr(value, "model_extra", None)
if isinstance(extra, dict):
return extra.get(field, extra.get(camel))
return None
return value.get(field, value.get(camel))
return {
field: _count(read(field))
Expand Down Expand Up @@ -129,8 +136,9 @@ def _subtract_counts(
def normalize_codex_usage(
first_usage: Any | None,
final_usage: Any | None,
call_usages: list[Any] | None = None,
) -> dict | None:
"""Derive one Möbius-turn aggregate from Codex thread usage updates."""
"""Derive one turn aggregate plus exact per-model-call billing inputs."""
if final_usage is None:
return None
first_usage = first_usage or final_usage
Expand Down Expand Up @@ -159,21 +167,31 @@ def normalize_codex_usage(

input_total = turn["input_tokens"]
cached = min(turn["cached_input_tokens"], input_total)
cache_write = min(
turn["cache_write_input_tokens"],
max(0, input_total - cached),
)
model_calls = [
_codex_breakdown(call)
for call in (call_usages or [])
if call is not None
]
return {
"provider": "codex",
"scope": "turn",
"calculation": calculation,
"input_tokens": input_total,
"uncached_input_tokens": max(0, input_total - cached),
"uncached_input_tokens": max(0, input_total - cached - cache_write),
"output_tokens": turn["output_tokens"],
"cache_read_input_tokens": cached,
"cache_creation_input_tokens": 0,
"cache_creation_input_tokens": cache_write,
"reasoning_output_tokens": turn["reasoning_output_tokens"],
"total_tokens": turn["total_tokens"],
"model_context_window": _count(
_member(final_usage, "model_context_window")
) or None,
"provider_thread_total": final_total,
"model_calls": model_calls,
"provider_usage": {
"first": _plain(first_usage),
"final": _plain(final_usage),
Expand All @@ -184,9 +202,6 @@ def normalize_codex_usage(
# OpenAI Codex per-token USD rates as (uncached_input, cached_input_read,
# output) dollars per 1,000,000 tokens. Sourced from OpenAI's published API
# pricing (July 2026); cached reads are the standard 90%-discounted input rate.
# The separate long-context surcharge tier is intentionally NOT modeled — these
# are the standard-context rates, so a turn that crosses the long-context
# threshold is a small, bounded underestimate rather than a wrong number.
# Update these as OpenAI revises pricing; a model absent from this table is left
# uncharged (cost None) rather than mispriced.
CODEX_MODEL_RATES: dict[str, tuple[float, float, float]] = {
Expand All @@ -198,6 +213,43 @@ def normalize_codex_usage(
"gpt-5.4-mini": (0.75, 0.075, 4.50),
}

_CACHE_WRITE_MODELS = frozenset({
"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
})
_LONG_CONTEXT_MODELS = _CACHE_WRITE_MODELS
_LONG_CONTEXT_INPUT_THRESHOLD = 272_000


def _codex_call_cost(
model: str,
counts: dict[str, Any],
rates: tuple[float, float, float],
) -> float:
"""Price one upstream model call, including request-scoped surcharges."""
in_rate, cached_rate, out_rate = rates
input_total = _count(counts.get("input_tokens"))
cached = min(_count(counts.get("cached_input_tokens")), input_total)
cache_write = min(
_count(counts.get("cache_write_input_tokens")),
max(0, input_total - cached),
)
uncached = max(0, input_total - cached - cache_write)
cache_write_rate = in_rate * (1.25 if model in _CACHE_WRITE_MODELS else 1.0)
if (
model in _LONG_CONTEXT_MODELS
and input_total > _LONG_CONTEXT_INPUT_THRESHOLD
):
in_rate *= 2
cached_rate *= 2
cache_write_rate *= 2
out_rate *= 1.5
return (
uncached * in_rate
+ cached * cached_rate
+ cache_write * cache_write_rate
+ _count(counts.get("output_tokens")) * out_rate
) / 1_000_000


def codex_cost_usd(model: str | None, usage_metrics: dict | None) -> float | None:
"""Best-effort USD cost for one Codex turn from its normalized usage.
Expand All @@ -215,10 +267,26 @@ def codex_cost_usd(model: str | None, usage_metrics: dict | None) -> float | Non
if rates is None:
return None
in_rate, cached_rate, out_rate = rates
model_calls = usage_metrics.get("model_calls")
if isinstance(model_calls, list) and model_calls:
return round(sum(
_codex_call_cost(model, call, rates)
for call in model_calls if isinstance(call, dict)
), 6)

uncached = max(0, _count(usage_metrics.get("uncached_input_tokens")))
cached = max(0, _count(usage_metrics.get("cache_read_input_tokens")))
cache_write = max(
0, _count(usage_metrics.get("cache_creation_input_tokens"))
)
cache_write_rate = in_rate * (
1.25 if model in _CACHE_WRITE_MODELS else 1.0
)
output = max(0, _count(usage_metrics.get("output_tokens")))
cost = (
uncached * in_rate + cached * cached_rate + output * out_rate
uncached * in_rate
+ cached * cached_rate
+ cache_write * cache_write_rate
+ output * out_rate
) / 1_000_000
return round(cost, 6)
2 changes: 1 addition & 1 deletion backend/scripts/init_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
SKILLS = DATA_DIR / "shared" / "skills"
VERSION_FILE = SKILLS / ".seed-version"
SEED_VERSION = "21" # v21: carry exact app and generated-image handles
SEED_VERSION = "22" # v22: add symmetric Claude delegation
# Update only byte-for-byte baked copies; an owner/agent-edited file is never
# touched. A set preserves every known unmodified predecessor when one skill
# needs more than one fix-forward migration over its lifetime.
Expand Down
44 changes: 44 additions & 0 deletions backend/scripts/seed-skills/claude.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
name: claude
description: Read before handing a task to Claude Code from a Codex turn. Use the connected Claude CLI non-interactively, preserve the owner's requested model and effort, give it a bounded outcome-first prompt, wait for the result inside this turn, and report which provider did what.
---

# Delegating to Claude

Use this when the partner explicitly asks Codex to consult or delegate to
Claude, or when an independent Claude pass would materially improve an
authorized task. Möbius exposes `CLAUDE_CONFIG_DIR` to Codex only when Claude is
connected, so first confirm that variable exists and the `claude` executable is
available. If either is absent, say Claude is not connected rather than trying
another credential path.

Run Claude non-interactively and wait for it in the current turn:

```bash
claude -p --output-format text --model <model-or-alias> --effort <level> "<prompt>"
```

- Omit `--model` when the partner did not name one; Claude's configured default
is the honest default.
- Effort values are `low`, `medium`, `high`, `xhigh`, and `max`. Omit the flag
when there is no reason to override the default.
- Do not use `--background`: a Möbius helper must finish before this turn ends.
- Match the current task's authority. For code changes, tell Claude exactly what
it may edit and how to verify the result. For review or investigation, state
that it is read-only.
- Let the inherited `CLAUDE_CONFIG_DIR` select the connected account. Never
inspect, copy, print, or relocate its credential file.

Shape the prompt around the result:

```text
Goal: <specific outcome>
Where: <the files or system to inspect>
Constraints: <read-only or exact write scope; important boundaries>
Done when: <tests, evidence, or decision the response must contain>
```

Keep the prompt lean and point to real files instead of pasting large context.
After Claude returns, assess its work yourself, run the relevant verification,
and tell the partner which part came from Claude. Claude's response is evidence
or a candidate change, not a substitute for your own review.
33 changes: 33 additions & 0 deletions backend/tests/test_codex_sdk_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,39 @@ def test_lifecycle_notification_fields_and_status_enums_are_pinned():
}


def test_cache_write_usage_survives_generated_sdk_schema_lag():
"""The 5.6 cache-write counter must reach cost normalization."""
pytest.importorskip("openai_codex")
from openai_codex.generated import v2_all
from app import codex_sdk_runner

codex_sdk_runner._sdk_imports()
breakdown = {
"inputTokens": 100,
"cachedInputTokens": 10,
"cacheWriteInputTokens": 60,
"outputTokens": 20,
"reasoningOutputTokens": 5,
"totalTokens": 120,
}
payload = v2_all.ThreadTokenUsageUpdatedNotification.model_validate({
"threadId": "thread-1",
"turnId": "turn-1",
"tokenUsage": {
"last": breakdown,
"total": breakdown,
"modelContextWindow": 1_050_000,
},
})
assert payload.token_usage.last.model_extra == {
"cacheWriteInputTokens": 60,
}
assert codex_sdk_runner.normalize_codex_usage(
payload.token_usage,
payload.token_usage,
)["cache_creation_input_tokens"] == 60


def test_turn_terminal_status_and_message_phase_contracts_are_pinned():
"""The runner must not confuse a terminal envelope with a final answer."""
pytest.importorskip("openai_codex")
Expand Down
1 change: 1 addition & 0 deletions backend/tests/test_codex_sdk_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,7 @@ def model_dump(self, **_kwargs):
assert result["terminal_status"] == "interrupted"
assert result["usage"]["total"]["total_tokens"] == 1_100
assert "usage_metrics" in result
assert len(result["usage_metrics"]["model_calls"]) == 1


def test_run_codex_sdk_turn_unrequested_transport_death_stays_an_error(
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_memory_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def test_later_boot_migrates_only_unmodified_graph_aware_base_skill(
def test_controlled_skills_have_fix_forward_migrations():
module = _load("init_skills")

assert module.SEED_VERSION == "21"
assert module.SEED_VERSION == "22"
assert module._UNMODIFIED_MIGRATIONS["images.md"] == {
"248ea31e13d2d2d84a5acfca13526aa8ebfa3d90e9ee4bf55cfb72d47937f7d1",
"29039a6fc5c9281794247eda5d0bbf66e969a1a260e9ed56c69ee6e1cd175f7c",
Expand Down
Loading