Skip to content

Commit c45e036

Browse files
committed
Honor authoritative model payloads in Codex SDK
Keep caller-supplied model payloads authoritative across the\nCodex options surface and replace timing-based realtime test\nsynchronization with concrete websocket send acknowledgements.\n\nThis preserves the shared-core handoff and fixes async suite\nflakes without hiding them behind sync execution.
1 parent 0020f43 commit c45e036

9 files changed

Lines changed: 136 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- ChatGPT plan claims now normalize to the SDK's canonical lowercase strings,
3333
including `hc -> "enterprise"` and `education -> "edu"`, before auth/status
3434
structs and app-server external-auth payloads are built.
35+
- App-server connections no longer live-echo child stderr during healthy
36+
operation; retained stderr is still preserved for typed failure reporting.
3537

3638
## [0.15.0] - 2026-03-19
3739

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ and `reasoning_effort` from the authoritative shared `model_payload`.
148148
`Codex.Models` is now a read-only projection of the shared core catalog. It no
149149
longer owns a separate catalog or a separate fallback/defaulting path.
150150

151+
If a caller supplies an explicit `model_payload`, that payload stays
152+
authoritative. Repo-local env defaults such as `CODEX_MODEL`,
153+
`CODEX_PROVIDER_BACKEND`, and `CODEX_OLLAMA_BASE_URL` are fallback inputs only
154+
when the payload was not supplied explicitly.
155+
151156
Operationally, that means:
152157

153158
- explicit request wins first

guides/03-api-guide.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ public API surfaces are projections and consumers of that shared contract.
4343

4444
Primary entrypoints:
4545

46-
- `Codex.Options.new/1` resolves the shared `model_payload` via
47-
`CliSubprocessCore.ModelRegistry.build_arg_payload/3`
46+
- `Codex.Options.new/1` normalizes mixed raw-versus-payload input through
47+
`CliSubprocessCore.ModelInput.normalize/3`
4848
- `Codex.Models.default_model/0` returns the current shared core default
4949
- `Codex.Models.list_visible/1` returns the shared visible Codex catalog
5050
- `Codex.Models.default_reasoning_effort/1` projects reasoning defaults from
@@ -57,6 +57,8 @@ Operational notes:
5757
from the core registry contract
5858
- runtime CLI rendering consumes resolved state and does not invent fallback
5959
models locally
60+
- repo-local env defaults are only consulted when `model_payload` was not
61+
supplied explicitly
6062

6163
Example:
6264

lib/codex/app_server/connection.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ defmodule Codex.AppServer.Connection do
287287
{:codex_io_transport, ref, {:stderr, data}},
288288
%State{raw_session: %RawSession{transport_ref: ref}} = state
289289
) do
290-
Logger.debug("codex app-server stderr: #{String.trim(IO.iodata_to_binary(data))}")
290+
_ = data
291291
{:noreply, state}
292292
end
293293

@@ -344,7 +344,7 @@ defmodule Codex.AppServer.Connection do
344344
{:stderr, ref, data},
345345
%State{raw_session: %RawSession{transport_ref: ref}} = state
346346
) do
347-
Logger.debug("codex app-server stderr: #{String.trim(IO.iodata_to_binary(data))}")
347+
_ = data
348348
{:noreply, state}
349349
end
350350

lib/codex/options.ex

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -225,16 +225,27 @@ defmodule Codex.Options do
225225
end
226226

227227
defp apply_model_env_defaults(attrs) when is_map(attrs) do
228-
attrs
229-
|> put_missing_attr(
230-
:env_model,
231-
System.get_env("CODEX_MODEL") ||
232-
System.get_env("OPENAI_DEFAULT_MODEL") ||
233-
System.get_env("CODEX_MODEL_DEFAULT")
234-
)
235-
|> put_missing_attr(:provider_backend, System.get_env("CODEX_PROVIDER_BACKEND"))
236-
|> put_missing_attr(:oss_provider, System.get_env("CODEX_OSS_PROVIDER"))
237-
|> put_missing_attr(:ollama_base_url, System.get_env("CODEX_OLLAMA_BASE_URL"))
228+
if explicit_model_payload?(attrs) do
229+
attrs
230+
else
231+
attrs
232+
|> put_missing_attr(
233+
:env_model,
234+
System.get_env("CODEX_MODEL") ||
235+
System.get_env("OPENAI_DEFAULT_MODEL") ||
236+
System.get_env("CODEX_MODEL_DEFAULT")
237+
)
238+
|> put_missing_attr(:provider_backend, System.get_env("CODEX_PROVIDER_BACKEND"))
239+
|> put_missing_attr(:oss_provider, System.get_env("CODEX_OSS_PROVIDER"))
240+
|> put_missing_attr(:ollama_base_url, System.get_env("CODEX_OLLAMA_BASE_URL"))
241+
end
242+
end
243+
244+
defp explicit_model_payload?(attrs) when is_map(attrs) do
245+
case Map.get(attrs, :model_payload, Map.get(attrs, "model_payload")) do
246+
nil -> false
247+
_payload -> true
248+
end
238249
end
239250

240251
defp put_missing_attr(attrs, _key, nil), do: attrs

test/codex/app_server/connection_test.exs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ defmodule Codex.AppServer.ConnectionTest do
22
use ExUnit.Case, async: true
33
@moduletag capture_log: true
44

5+
import ExUnit.CaptureLog
6+
57
alias CliSubprocessCore.RawSession
68
alias Codex.AppServer.Connection
79
alias Codex.AppServer.Protocol
@@ -159,6 +161,30 @@ defmodule Codex.AppServer.ConnectionTest do
159161
assert raw_session.stdin_mode == :raw
160162
end
161163

164+
test "healthy child stderr does not produce live debug logs", %{codex_opts: codex_opts} do
165+
{:ok, conn} =
166+
Connection.start_link(codex_opts,
167+
transport: {AppServerSubprocess, owner: self()},
168+
init_timeout_ms: 200
169+
)
170+
171+
assert_receive {:app_server_subprocess_started, ^conn, transport_ref}
172+
assert_receive {:app_server_subprocess_send, ^conn, init_line}
173+
assert {:ok, %{"id" => 0}} = Jason.decode(init_line)
174+
send(conn, {:stdout, transport_ref, Protocol.encode_response(0, %{})})
175+
assert :ok == Connection.await_ready(conn, 200)
176+
assert_receive {:app_server_subprocess_send, ^conn, _initialized_line}
177+
178+
log =
179+
capture_log([level: :debug], fn ->
180+
send(conn, {:codex_io_transport, transport_ref, {:stderr, "non-fatal child stderr"}})
181+
Process.sleep(20)
182+
end)
183+
184+
refute log =~ "codex app-server stderr:"
185+
assert Process.alive?(conn)
186+
end
187+
162188
test "launch options reject invalid child env overrides", %{codex_opts: codex_opts} do
163189
assert {:error, {:invalid_env, ["/tmp/not-a-keyword"]}} =
164190
Connection.start_link(codex_opts,

test/codex/options_test.exs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@ defmodule Codex.OptionsTest do
66
import Codex.Test.ModelFixtures
77

88
setup do
9-
env_keys = ~w(CODEX_MODEL CODEX_MODEL_DEFAULT CODEX_API_KEY CODEX_HOME OPENAI_BASE_URL)
9+
env_keys =
10+
~w(
11+
CODEX_MODEL
12+
CODEX_MODEL_DEFAULT
13+
CODEX_PROVIDER_BACKEND
14+
CODEX_OSS_PROVIDER
15+
CODEX_OLLAMA_BASE_URL
16+
CODEX_API_KEY
17+
CODEX_HOME
18+
OPENAI_BASE_URL
19+
)
1020

1121
original_env =
1222
env_keys
@@ -193,6 +203,43 @@ defmodule Codex.OptionsTest do
193203
})
194204
end
195205

206+
test "does not treat env defaults as active config when model_payload is explicit" do
207+
payload =
208+
Selection.new(%{
209+
provider: :codex,
210+
requested_model: "llama3.2",
211+
resolved_model: "llama3.2",
212+
resolution_source: :explicit,
213+
reasoning: "high",
214+
reasoning_effort: nil,
215+
normalized_reasoning_effort: nil,
216+
model_family: "llama",
217+
catalog_version: nil,
218+
visibility: :public,
219+
provider_backend: :oss,
220+
model_source: :external,
221+
env_overrides: %{"CODEX_OSS_BASE_URL" => "http://127.0.0.1:22434"},
222+
settings_patch: %{},
223+
backend_metadata: %{
224+
"provider_backend" => "oss",
225+
"oss_provider" => "ollama",
226+
"external_model" => "llama3.2",
227+
"support_tier" => "runtime_validated_only"
228+
},
229+
errors: []
230+
})
231+
232+
System.put_env("CODEX_MODEL", "gpt-5.4")
233+
System.put_env("CODEX_PROVIDER_BACKEND", "openai")
234+
System.put_env("CODEX_OSS_PROVIDER", "other")
235+
System.put_env("CODEX_OLLAMA_BASE_URL", "http://127.0.0.1:11434")
236+
237+
assert {:ok, opts} = Options.new(%{model_payload: payload})
238+
assert opts.model_payload == payload
239+
assert opts.model == "llama3.2"
240+
assert opts.reasoning_effort == :high
241+
end
242+
196243
test "does not crash when CODEX_MODEL points at a cross-catalog model under api auth" do
197244
auth_path = Path.join(System.get_env("CODEX_HOME"), "auth.json")
198245
File.write!(auth_path, ~s({"OPENAI_API_KEY":"sk-test"}))

test/codex/realtime/session_test.exs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ defmodule Codex.Realtime.SessionTest do
250250
:ok = Session.subscribe(session, self())
251251
:ok = Session.send_message(session, "first")
252252
:ok = MockWebSocket.clear_sent_messages(mock_ws)
253+
drain_websocket_sent_messages()
253254

254255
:ok = Session.send_message(session, "second")
255256

@@ -272,9 +273,7 @@ defmodule Codex.Realtime.SessionTest do
272273
)
273274

274275
assert_receive {:session_event, %Events.AgentEndEvent{}}
275-
276-
messages = MockWebSocket.get_sent_messages(mock_ws)
277-
assert Enum.any?(messages, &(&1["type"] == "response.create"))
276+
assert_receive {:websocket_sent, %{"type" => "response.create"}}
278277

279278
Session.close(session)
280279
end
@@ -306,6 +305,7 @@ defmodule Codex.Realtime.SessionTest do
306305
:ok = Session.subscribe(session, self())
307306
:ok = Session.send_message(session, "start")
308307
:ok = MockWebSocket.clear_sent_messages(mock_ws)
308+
drain_websocket_sent_messages()
309309

310310
tool_call =
311311
ModelEvents.tool_call(
@@ -316,17 +316,18 @@ defmodule Codex.Realtime.SessionTest do
316316
)
317317

318318
send(session, {:model_event, tool_call})
319-
Process.sleep(50)
320319

321-
messages = MockWebSocket.get_sent_messages(mock_ws)
322-
323-
assert Enum.any?(messages, fn msg ->
324-
msg["type"] == "conversation.item.create" and
325-
get_in(msg, ["item", "type"]) == "function_call_output" and
326-
get_in(msg, ["item", "call_id"]) == "call_overlap"
327-
end)
320+
assert_receive {:websocket_sent,
321+
%{
322+
"type" => "conversation.item.create",
323+
"item" => %{
324+
"type" => "function_call_output",
325+
"call_id" => "call_overlap"
326+
}
327+
}},
328+
1_000
328329

329-
refute Enum.any?(messages, &(&1["type"] == "response.create"))
330+
refute_receive {:websocket_sent, %{"type" => "response.create"}}, 50
330331

331332
send(
332333
session,
@@ -338,9 +339,7 @@ defmodule Codex.Realtime.SessionTest do
338339
)
339340

340341
assert_receive {:session_event, %Events.AgentEndEvent{}}
341-
342-
messages = MockWebSocket.get_sent_messages(mock_ws)
343-
assert Enum.any?(messages, &(&1["type"] == "response.create"))
342+
assert_receive {:websocket_sent, %{"type" => "response.create"}}
344343

345344
Session.close(session)
346345
end
@@ -1130,4 +1129,12 @@ defmodule Codex.Realtime.SessionTest do
11301129
false
11311130
end
11321131
end
1132+
1133+
defp drain_websocket_sent_messages do
1134+
receive do
1135+
{:websocket_sent, _message} -> drain_websocket_sent_messages()
1136+
after
1137+
0 -> :ok
1138+
end
1139+
end
11331140
end

test/support/mock_websocket.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ defmodule Codex.Test.MockWebSocket do
8181
"""
8282
@spec send_frame(GenServer.server(), {:text, String.t()}) :: :ok
8383
def send_frame(pid, {:text, json}) do
84-
GenServer.cast(pid, {:send_to_server, Jason.decode!(json)})
85-
:ok
84+
GenServer.call(pid, {:send_to_server, Jason.decode!(json)})
8685
end
8786

8887
@doc """
@@ -118,6 +117,11 @@ defmodule Codex.Test.MockWebSocket do
118117
end
119118

120119
@impl true
120+
def handle_call({:send_to_server, message}, _from, state) do
121+
send(state.test_pid, {:websocket_sent, message})
122+
{:reply, :ok, %{state | sent_messages: [message | state.sent_messages]}}
123+
end
124+
121125
def handle_call(:get_sent_messages, _from, state) do
122126
{:reply, Enum.reverse(state.sent_messages), state}
123127
end

0 commit comments

Comments
 (0)