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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ See `changelogs/v1.5.0-beta.1.md` for the full themed summary with credits.
- **core**: always emit absolute paths from `SaveFilesToDisk` / `AppendFileRefs` — fixes relative `work_dir` attachments silently dropped by agent (#1462 fixing #1459, @chenhg5).
- **core**: create queue placeholder before session lock — prevents concurrent message queue miss (#1389, @xxb).
- **codex**: time out blocked app-server writes (#1448, @AaronZ345).
- **codex**: surface GPT-5.x and future chat-model families from API model discovery instead of filtering them through a stale static allowlist (#1546, @cg33).
- **slack**: suppress `NO_REPLY` marker on streaming-card silent replies (#1397, @spinsirr).

## v1.4.1 (2026-06-28)
Expand Down
18 changes: 17 additions & 1 deletion agent/codex/appserver_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ type appServerSession struct {
workDir string
model string
effort string
effortOverride bool
mode string
baseURL string
modelProvider string
Expand Down Expand Up @@ -216,6 +217,7 @@ func newAppServerSession(ctx context.Context, url, workDir, model, effort, mode,
workDir: workDir,
model: model,
effort: effort,
effortOverride: strings.TrimSpace(effort) != "",
mode: mode,
baseURL: baseURL,
modelProvider: modelProvider,
Expand Down Expand Up @@ -408,7 +410,9 @@ func (s *appServerSession) applyThreadRuntimeState(workDir, model string, effort
if m := strings.TrimSpace(model); m != "" {
s.model = m
}
s.effort = normalizeRuntimeReasoningEffort(stringValue(effort))
if !s.effortOverride {
s.effort = normalizeRuntimeReasoningEffort(stringValue(effort))
}
}

func (s *appServerSession) refreshUsage(ctx context.Context) error {
Expand Down Expand Up @@ -1003,6 +1007,18 @@ func (s *appServerSession) GetReasoningEffort() string {
return strings.TrimSpace(s.effort)
}

func (s *appServerSession) SetLiveReasoningEffort(effort string) bool {
normalized := normalizeReasoningEffort(effort)
if normalized == "" && strings.TrimSpace(effort) != "" {
return false
}
s.runtimeMu.Lock()
s.effort = normalized
s.effortOverride = true
s.runtimeMu.Unlock()
return true
}

func (s *appServerSession) GetUsage(ctx context.Context) (*core.UsageReport, error) {
if err := s.refreshUsage(ctx); err != nil {
if cached := s.cachedUsage(); cached != nil {
Expand Down
33 changes: 33 additions & 0 deletions agent/codex/appserver_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ func TestAppServerSession_ApplyThreadRuntimeState(t *testing.T) {
}
}

func TestAppServerSession_SetLiveReasoningEffortPreservesThread(t *testing.T) {
s := &appServerSession{effort: "high"}
s.threadID.Store("thread-existing")

if !s.SetLiveReasoningEffort("ultra") {
t.Fatal("SetLiveReasoningEffort(ultra) = false, want true")
}

if got := s.CurrentSessionID(); got != "thread-existing" {
t.Fatalf("CurrentSessionID() = %q, want thread-existing", got)
}
if got := s.GetReasoningEffort(); got != "ultra" {
t.Fatalf("GetReasoningEffort() = %q, want ultra", got)
}

previousThreadEffort := "high"
s.applyThreadRuntimeState("/tmp/project", "gpt-5.6-sol", &previousThreadEffort)
if got := s.GetReasoningEffort(); got != "ultra" {
t.Fatalf("GetReasoningEffort() after thread resume = %q, want explicit ultra override", got)
}
}

func TestAppServerSession_ExplicitEffortSurvivesThreadResume(t *testing.T) {
s := &appServerSession{effort: "ultra", effortOverride: true}
previousThreadEffort := "low"

s.applyThreadRuntimeState("/tmp/project", "gpt-5.6-sol", &previousThreadEffort)

if got := s.GetReasoningEffort(); got != "ultra" {
t.Fatalf("GetReasoningEffort() = %q, want explicit ultra override", got)
}
}

func TestAppServerSession_HandleRateLimitsUpdatedCachesUsage(t *testing.T) {
s := &appServerSession{}
raw, err := json.Marshal(appServerRateLimitsResponse{
Expand Down
84 changes: 74 additions & 10 deletions agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,22 @@ func normalizeReasoningEffort(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return ""
case "none", "off", "disabled", "disable":
return "none"
case "minimal", "min":
return "minimal"
case "low":
return "low"
case "medium", "med":
return "medium"
case "high":
return "high"
case "xhigh", "x-high", "very-high":
case "xhigh", "x-high", "extra-high", "extra_high", "very-high":
return "xhigh"
case "max", "maximum":
return "max"
case "ultra":
return "ultra"
default:
return ""
}
Expand Down Expand Up @@ -200,7 +208,11 @@ func (a *Agent) GetReasoningEffort() string {
}

func (a *Agent) AvailableReasoningEfforts() []string {
return []string{"low", "medium", "high", "xhigh"}
return []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
}

func (a *Agent) PreservesSessionOnReasoningEffortChange() bool {
return true
}

func (a *Agent) configuredModels() []core.ModelOption {
Expand All @@ -222,7 +234,19 @@ func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
if models := readCodexCachedModels(); len(models) > 0 {
return models
}
return defaultCodexModels()
}

func defaultCodexModels() []core.ModelOption {
return []core.ModelOption{
{Name: "gpt-5.6-sol", Desc: "GPT-5.6 Sol (strongest for complex Codex work)"},
{Name: "gpt-5.6-terra", Desc: "GPT-5.6 Terra (balanced everyday Codex work)"},
{Name: "gpt-5.6-luna", Desc: "GPT-5.6 Luna (fast, efficient GPT-5.6 model)"},
{Name: "gpt-5.6", Desc: "GPT-5.6 (recommended Codex model family default)"},
{Name: "gpt-5.5", Desc: "GPT-5.5 (previous frontier Codex model)"},
{Name: "gpt-5.4", Desc: "GPT-5.4 (frontier Codex model)"},
{Name: "gpt-5.4-mini", Desc: "GPT-5.4 Mini (fast Codex model)"},
{Name: "gpt-5.3-codex-spark", Desc: "GPT-5.3 Codex Spark (fast text-only iteration)"},
{Name: "o4-mini", Desc: "O4 Mini (fast reasoning)"},
{Name: "o3", Desc: "O3 (most capable reasoning)"},
{Name: "gpt-4.1", Desc: "GPT-4.1 (balanced)"},
Expand All @@ -232,11 +256,53 @@ func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
}
}

var openaiChatModels = map[string]bool{
"o4-mini": true, "o3": true, "o3-mini": true, "o1": true, "o1-mini": true,
"gpt-4.1": true, "gpt-4.1-mini": true, "gpt-4.1-nano": true,
"gpt-4o": true, "gpt-4o-mini": true,
"codex-mini-latest": true,
// nonChatSubstrings identifies non chat/completion modalities returned by
// GET /v1/models that must not appear in the codex /model chooser.
var nonChatSubstrings = []string{
"embedding", "whisper", "tts", "moderation", "dall-e",
"realtime", "transcribe", "search-preview", "image",
"audio-preview",
}

// isCodexChatModel reports whether an OpenAI-compatible model ID names a
// chat/completion model that Codex CLI can drive. Used to filter the
// /v1/models response into the /model command suggestion list.
//
// Rules (case-insensitive):
// - Reject any ID containing a non-chat modality substring (embedding,
// whisper, tts, dall-e, audio-preview, realtime, transcribe, moderation,
// image, search-preview).
// - Accept known chat family prefixes: gpt-*, chatgpt-*, codex-*, o1-*,
// o3-*, o4-*, o5-*.
// - Accept bare reasoning family IDs: o1 / o3 / o4 / o5.
//
// Uses pattern matching rather than a static allowlist so new frontier models
// (gpt-5.x, gpt-6, o5-*, codex-*, etc.) are picked up automatically.
func isCodexChatModel(id string) bool {
if id == "" {
return false
}
lower := strings.ToLower(id)
for _, s := range nonChatSubstrings {
if strings.Contains(lower, s) {
return false
}
}
switch {
case strings.HasPrefix(lower, "gpt-"),
strings.HasPrefix(lower, "chatgpt-"),
strings.HasPrefix(lower, "codex-"),
strings.HasPrefix(lower, "o1-"),
strings.HasPrefix(lower, "o3-"),
strings.HasPrefix(lower, "o4-"),
strings.HasPrefix(lower, "o5-"):
return true
}
switch lower {
case "o1", "o3", "o4", "o5":
return true
}
return false
}

func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
Expand Down Expand Up @@ -290,7 +356,7 @@ func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {

var models []core.ModelOption
for _, m := range result.Data {
if openaiChatModels[m.ID] {
if isCodexChatModel(m.ID) {
models = append(models, core.ModelOption{Name: m.ID})
}
}
Expand All @@ -311,7 +377,6 @@ func readCodexCachedModels() []core.ModelOption {
return parseCodexModelsJSON(b)
}


// parseCodexModelsJSON parses a Codex models JSON file (model_catalog.json
// or models_cache.json) into a deduplicated, filtered slice of ModelOption.
// It is shared by readCodexCachedModels and readCodexModelCatalog.
Expand Down Expand Up @@ -357,7 +422,6 @@ func parseCodexModelsJSON(data []byte) []core.ModelOption {
return models
}


// readCodexModelCatalog reads $CODEX_HOME/config.toml to find the
// model_catalog_json setting, then reads and parses that JSON file.
// This is the authoritative source of model metadata for Codex CLI,
Expand Down
12 changes: 9 additions & 3 deletions agent/codex/codex_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,14 @@ func TestReadCodexModelCatalog_NoConfigFile(t *testing.T) {
models := a.AvailableModels(context.Background())

// No config.toml → no model_catalog.json → no models_cache.json
// → no OPENAI_API_KEY → all the way to hardcoded fallback (6 models)
if len(models) != 6 {
t.Fatalf("expected 6 hardcoded fallback models, got %d: %v", len(models), models)
// → no OPENAI_API_KEY → all the way to hardcoded fallback.
want := []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6"}
if len(models) != len(defaultCodexModels()) {
t.Fatalf("expected %d hardcoded fallback models, got %d: %v", len(defaultCodexModels()), len(models), models)
}
for i, name := range want {
if models[i].Name != name {
t.Fatalf("fallback model %d = %q, want %q; models=%v", i, models[i].Name, name, models)
}
}
}
74 changes: 74 additions & 0 deletions agent/codex/codex_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,77 @@ func TestWorkspaceAgentOptions_PreservesStdIOAppServerURL(t *testing.T) {
t.Fatalf("WorkspaceAgentOptions()[app_server_url] = %#v, want stdio://", got)
}
}

func TestIsCodexChatModel(t *testing.T) {
tests := []struct {
id string
want bool
}{
{"", false},

// Legacy / current chat families (must keep working).
{"gpt-4o", true},
{"gpt-4o-mini", true},
{"gpt-4.1", true},
{"gpt-4.1-mini", true},
{"gpt-4.1-nano", true},
{"gpt-3.5-turbo", true},
{"chatgpt-4o-latest", true},
{"o1", true},
{"o1-mini", true},
{"o1-preview", true},
{"o3", true},
{"o3-mini", true},
{"o4", true},
{"o4-mini", true},
{"codex-mini-latest", true},

// GPT-5 series — the regression that motivated this change.
{"gpt-5", true},
{"gpt-5-mini", true},
{"gpt-5.3", true},
{"gpt-5.3-codex", true},
{"gpt-5.4", true},
{"gpt-5.5", true},
{"gpt-5.6", true},
{"gpt-5.6-sol", true},
{"gpt-5.6-terra", true},
{"gpt-5.6-luna", true},

// Case insensitivity (defensive; ids from /v1/models are usually lower).
{"GPT-5.6", true},
{"Codex-Mini-Latest", true},

// Non-chat modalities that /v1/models returns — must be rejected so
// they never show up in the /model chooser.
{"text-embedding-ada-002", false},
{"text-embedding-3-small", false},
{"text-embedding-3-large", false},
{"whisper-1", false},
{"tts-1", false},
{"tts-1-hd", false},
{"gpt-4o-realtime-preview", false},
{"gpt-4o-audio-preview", false},
{"gpt-4o-transcribe", false},
{"gpt-4o-search-preview", false},
{"dall-e-2", false},
{"dall-e-3", false},
{"gpt-image-1", false},
{"text-moderation-latest", false},
{"omni-moderation-latest", false},

// Unrelated model families that should not be surfaced.
{"babbage-002", false},
{"davinci-002", false},
{"claude-3-5-sonnet", false},
{"gemini-1.5-pro", false},
}

for _, tc := range tests {
t.Run(tc.id, func(t *testing.T) {
if got := isCodexChatModel(tc.id); got != tc.want {
t.Fatalf("isCodexChatModel(%q) = %v, want %v", tc.id, got, tc.want)
}
})
}
}
24 changes: 21 additions & 3 deletions agent/codex/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type codexSession struct {
closeOnce sync.Once
cmdMu sync.Mutex
cmds map[*exec.Cmd]struct{}
effortMu sync.RWMutex

pendingMsgs []string // buffered agent_message texts awaiting classification

Expand Down Expand Up @@ -262,8 +263,8 @@ func (cs *codexSession) buildExecArgs(prompt string, imagePaths []string) []stri
if cs.baseURL != "" {
args = append(args, "-c", fmt.Sprintf("openai_base_url=%q", cs.baseURL))
}
if cs.effort != "" {
args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", cs.effort))
if effort := cs.explicitReasoningEffort(); effort != "" {
args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", effort))
}

if isResume {
Expand Down Expand Up @@ -811,13 +812,30 @@ func (cs *codexSession) GetModel() string {
}

func (cs *codexSession) GetReasoningEffort() string {
if effort := strings.TrimSpace(cs.effort); effort != "" {
if effort := cs.explicitReasoningEffort(); effort != "" {
return effort
}
_, effort := cs.runtimeConfig()
return effort
}

func (cs *codexSession) explicitReasoningEffort() string {
cs.effortMu.RLock()
defer cs.effortMu.RUnlock()
return strings.TrimSpace(cs.effort)
}

func (cs *codexSession) SetLiveReasoningEffort(effort string) bool {
normalized := normalizeReasoningEffort(effort)
if normalized == "" && strings.TrimSpace(effort) != "" {
return false
}
cs.effortMu.Lock()
cs.effort = normalized
cs.effortMu.Unlock()
return true
}

func (cs *codexSession) Alive() bool {
return cs.alive.Load()
}
Expand Down
Loading