From d80cc3d99aeb0a7964cecf587b34fee4bee92cab Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 14:29:10 +0800 Subject: [PATCH 1/2] feat(codex): choose reasoning effort and fast mode per turn Codex's model/list reports each model's supported reasoning efforts and service tiers, but the app only read id and name, and turn/start carried neither setting, so the thinking chip never appeared for Codex and fast mode could not be turned on. - CodexModels fills variants from supportedReasoningEfforts (server order) and records the fast tier, its description, and each model's default effort and service tier - every turn sends explicit effort and serviceTier: the app-server applies them to subsequent turns too, so an omitted value would silently keep the previous turn's (resolveCodexTurnSettings) - a global fast-mode choice (null = follow each model's default) is persisted and exposed to the chat as fastModeAvailable/Enabled - composer gets a Fast chip next to the thinking chip; labels for the none/minimal/ultra efforts in all locales - verified against codex 0.163.0 generated app-server types and a live model/list response --- .../andcode/core/api/OpenCodeApiModels.kt | 25 +++ .../connection/SecureSettingsRepository.kt | 13 ++ .../data/settings/AppPreferencesRepository.kt | 8 + .../andcode/di/ViewModelModule.kt | 7 + .../andcode/feature/chat/ChatHomeScreen.kt | 162 +++++++++++++++++- .../andcode/feature/chat/ChatViewModel.kt | 81 +++++++++ .../chat/ModelAndRuntimePickerSheet.kt | 11 ++ .../andcode/runtime/local/CodexModels.kt | 65 ++++++- .../andcode/runtime/local/CodexRuntime.kt | 66 ++++++- .../andcode/runtime/local/CodexTarget.kt | 44 ++++- .../yugahashimoto/andcode/ui/AndCodeApp.kt | 6 + app/src/main/res/values-ar/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-ja/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values-ru/strings.xml | 6 + app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../andcode/feature/chat/ChatViewModelTest.kt | 104 +++++++++++ .../feature/chat/CodexServiceTierTest.kt | 33 ++++ .../andcode/runtime/local/CodexModelsTest.kt | 101 +++++++++++ .../runtime/local/CodexTurnStartParamsTest.kt | 123 +++++++++++++ docs/CODEX.md | 36 ++++ 24 files changed, 919 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt index c898e79ed..ce0320d45 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt @@ -137,6 +137,26 @@ data class OpenCodeModel( val status: String? = null, val limit: OpenCodeModelLimit? = null, val variants: Map = emptyMap(), + /** + * Codex only: the service-tier id that means "Fast" for this model (the app-server's `priority` + * tier, or a legacy `fast`), or null when the model offers no fast mode. Named so no OpenCode + * payload carries the key; left serialized so the disk catalogue cache keeps it. + */ + val codexFastServiceTier: String? = null, + /** Codex only: the server's description of the fast tier, shown beside the fast-mode toggle. */ + val codexFastDescription: String? = null, + /** Codex only: true when the server defaults this model to the fast tier. */ + val codexFastByDefault: Boolean = false, + /** + * Codex only: the model's `defaultReasoningEffort`. Sent explicitly on every turn, because the + * app-server applies `effort` to subsequent turns too and omitting it would inherit the previous one. + */ + val codexDefaultEffort: String? = null, + /** + * Codex only: the model's `defaultServiceTier`, or null for standard speed. Sent explicitly on + * every turn for the same reason as [codexDefaultEffort]. + */ + val codexDefaultServiceTier: String? = null, ) @Serializable @@ -295,6 +315,11 @@ data class PromptRequest( val variant: String? = null, val attachments: List = emptyList(), val noReply: Boolean = false, + /** + * Codex only: the service tier for this turn. Null leaves the choice to Codex; `"default"` + * forces standard speed. Built field by field for OpenCode, so it is never sent to that server. + */ + val serviceTier: String? = null, ) @Serializable diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt index 2422c3d10..4fa91afb4 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt @@ -409,6 +409,18 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore, Unrea get() = preferences.getBoolean(KEY_AUTO_EXPAND_REASONING, false) set(value) = preferences.edit().putBoolean(KEY_AUTO_EXPAND_REASONING, value).apply() + /** + * Codex fast mode: null follows each model's own default, true or false is an explicit choice. + * Stored as a removable key so "never chosen" and "chosen" stay distinct across restarts. + */ + var codexFastMode: Boolean? + get() = if (preferences.contains(KEY_CODEX_FAST_MODE)) preferences.getBoolean(KEY_CODEX_FAST_MODE, false) else null + set(value) { + val editor = preferences.edit() + if (value == null) editor.remove(KEY_CODEX_FAST_MODE) else editor.putBoolean(KEY_CODEX_FAST_MODE, value) + editor.apply() + } + var sendBehavior: String get() = preferences.getString(KEY_SEND_BEHAVIOR, "interrupt") ?: "interrupt" set(value) = preferences.edit().putString(KEY_SEND_BEHAVIOR, value).apply() @@ -592,6 +604,7 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore, Unrea private const val KEY_SYNTAX_THEME = "syntax_theme" private const val KEY_TOOL_CALL_DETAIL_LEVEL = "tool_call_detail_level" private const val KEY_AUTO_EXPAND_REASONING = "auto_expand_reasoning" + private const val KEY_CODEX_FAST_MODE = "codex_fast_mode" private const val KEY_SEND_BEHAVIOR = "send_behavior" private const val KEY_ENTER_TO_SEND = "enter_to_send" private const val KEY_SIDEBAR_GROUPING = "sidebar_grouping" diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt index ac0c39531..07747b0f6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt @@ -43,6 +43,8 @@ data class AppPreferences( val syntaxTheme: String = "one-dark", val toolCallDetailLevel: String = "detailed", val autoExpandReasoning: Boolean = false, + /** Codex fast mode: null follows each model's default, otherwise the user's explicit choice. */ + val codexFastMode: Boolean? = null, val sendBehavior: String = "interrupt", val enterToSend: Boolean = false, val sidebarGrouping: String = "project", @@ -93,6 +95,7 @@ class AppPreferencesRepository( syntaxTheme = settings.syntaxTheme, toolCallDetailLevel = settings.toolCallDetailLevel, autoExpandReasoning = settings.autoExpandReasoning, + codexFastMode = settings.codexFastMode, sendBehavior = settings.sendBehavior, enterToSend = settings.enterToSend, sidebarGrouping = settings.sidebarGrouping, @@ -302,6 +305,11 @@ class AppPreferencesRepository( mutableState.update { it.copy(autoExpandReasoning = enabled) } } + fun setCodexFastMode(enabled: Boolean?) { + settings.codexFastMode = enabled + mutableState.update { it.copy(codexFastMode = enabled) } + } + fun setSendBehavior(behavior: String) { settings.sendBehavior = behavior mutableState.update { it.copy(sendBehavior = behavior) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt index 3fb75b63a..12a6fc57c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt @@ -1,6 +1,8 @@ package com.yugahashimoto.andcode.di import com.yugahashimoto.andcode.AndCodeApplication +import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository +import com.yugahashimoto.andcode.data.settings.AppPreferencesRepository import com.yugahashimoto.andcode.feature.activity.ActivityViewModel import com.yugahashimoto.andcode.feature.chat.ChatViewModel import com.yugahashimoto.andcode.feature.settings.SettingsViewModel @@ -17,9 +19,14 @@ val viewModelModule = viewModel { val app = androidContext().applicationContext as AndCodeApplication + val preferences: AppPreferencesRepository = get() + val settings: SecureSettingsRepository = get() ChatViewModel( draftRepo = get(), pullRequestStatuses = get(), + // Same Codex fast-mode wiring as the factory in ui/AndCodeApp.kt; keep the two in step. + onCodexFastModeChanged = preferences::setCodexFastMode, + initialCodexFastMode = settings.codexFastMode, // Kept in step with the hand-rolled factory in ui/AndCodeApp.kt: both construction // paths have to park the connection probe and stall watchdog while the app is // backgrounded, or whichever one the screen happens to use decides whether those diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 1d30bfc12..7c73ab112 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -53,6 +54,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close @@ -112,6 +114,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -128,6 +131,7 @@ import androidx.core.content.ContextCompat import com.yugahashimoto.andcode.R import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeCommand +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.OpenCodeSkill import com.yugahashimoto.andcode.core.api.PromptAttachment @@ -152,6 +156,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive /** What the long-press action sheet offers for one message: its id, its text to copy, and whether "Edit & resend" applies. */ private data class MessageActionTarget( @@ -178,6 +183,11 @@ fun ChatHomeScreen( onSelectAgent: (String) -> Unit, selectedVariant: String? = null, onSelectVariant: (String?) -> Unit = {}, + /** + * Turns Codex's fast service tier on or off. The chip for it shows only while + * [ChatUiState.fastModeAvailable], i.e. for a Codex model that lists the tier. + */ + onToggleFastMode: (Boolean) -> Unit = {}, attachments: List = emptyList(), onAttach: () -> Unit = {}, onRemoveAttachment: (Int) -> Unit = {}, @@ -651,6 +661,10 @@ fun ChatHomeScreen( ?.variants?.keys?.toList() ?: emptyList(), selectedVariant = state.selectedVariant, onSelectVariant = onSelectVariant, + fastModeAvailable = state.fastModeAvailable, + fastModeEnabled = state.fastModeEnabled, + fastModeDescription = state.fastModeDescription, + onToggleFastMode = onToggleFastMode, attachments = state.attachments, onAttach = onAttach, onRemoveAttachment = onRemoveAttachment, @@ -1241,6 +1255,10 @@ private fun ChatComposer( thinkingOptions: List, selectedVariant: String?, onSelectVariant: (String?) -> Unit, + fastModeAvailable: Boolean, + fastModeEnabled: Boolean, + fastModeDescription: String?, + onToggleFastMode: (Boolean) -> Unit, attachments: List, onAttach: () -> Unit, onRemoveAttachment: (Int) -> Unit, @@ -1619,6 +1637,15 @@ private fun ChatComposer( onSelect = onSelectVariant, ) } + // Right after the effort chip: both decide how the model runs the next turn, so they + // read as a pair. Only a Codex model with a fast tier offers it. + if (fastModeAvailable) { + FastModeChip( + enabled = fastModeEnabled, + description = fastModeDescription, + onToggle = onToggleFastMode, + ) + } if (systemPromptPresets.isNotEmpty()) { SystemPromptChip( presets = systemPromptPresets, @@ -1722,6 +1749,66 @@ private fun ThinkingChip( } } +/** + * Turns Codex's fast service tier on or off for the turns that follow. + * + * A plain on/off pill in the same tokens as the thinking and auto-accept chips, so it reads as one + * more setting on the row rather than a new kind of control. It carries switch semantics rather + * than a click, so a screen reader also says whether it is on. The server's own wording for the + * tier ("1.5x speed, increased usage") goes to the screen reader: a phone-width row has no room + * for it. + */ +@Composable +private fun FastModeChip( + enabled: Boolean, + description: String?, + onToggle: (Boolean) -> Unit, +) { + val containerColor = + if (enabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + modifier = + Modifier + .clip(RoundedCornerShape(100.dp)) + .toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle) + .testTag("chat-fast-mode"), + shape = RoundedCornerShape(100.dp), + color = containerColor, + contentColor = contentColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Default.Bolt, + contentDescription = + description + ?.takeIf { it.isNotBlank() } + ?.let { stringResource(R.string.cd_fast_mode_with_description, it) } + ?: stringResource(R.string.cd_fast_mode), + modifier = Modifier.size(14.dp), + ) + Text( + stringResource(R.string.chat_fast_mode), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } + } +} + /** * Switches the system-prompt preset this chat sends with, without leaving it. * @@ -1800,20 +1887,26 @@ private fun SystemPromptChip( /** * A model variant's display label for the thinking chip. * - * Claude Code and Antigravity both offer reasoning-effort slugs ("low", "medium", "high", plus - * Claude Code's own "xhigh" and "max"); those get their proper names ("Extra High", "Max") instead - * of the raw slug, which used to make the CLI's highest level ("max") look like just another word - * next to "xhigh" rather than the new top tier it is. Any other provider's variant id - OpenCode's - * own models offer different ones - falls back to capitalizing the raw value, as before. + * Claude Code, Antigravity and Codex all offer reasoning-effort slugs ("low", "medium", "high", + * plus Claude Code's and Codex's "xhigh" and "max"); those get their proper names ("Extra High", + * "Max") instead of the raw slug, which used to make the CLI's highest level ("max") look like just + * another word next to "xhigh" rather than the new top tier it is. Codex adds "ultra" above "max" + * and "minimal" and "none" below "low"; "none" reads "Off", since the chip then names thinking + * that is switched off, and a bare "None" would look like no choice was made. Any other provider's + * variant id - OpenCode's own models offer different ones - falls back to capitalizing the raw + * value, as before. */ @Composable private fun thinkingLevelLabel(level: String): String = when (level.lowercase()) { + "none" -> stringResource(R.string.chat_thinking_level_none) + "minimal" -> stringResource(R.string.chat_thinking_level_minimal) "low" -> stringResource(R.string.chat_thinking_level_low) "medium" -> stringResource(R.string.chat_thinking_level_medium) "high" -> stringResource(R.string.chat_thinking_level_high) "xhigh" -> stringResource(R.string.chat_thinking_level_xhigh) "max" -> stringResource(R.string.chat_thinking_level_max) + "ultra" -> stringResource(R.string.chat_thinking_level_ultra) else -> level.replaceFirstChar { it.uppercase() } } @@ -2215,3 +2308,62 @@ private fun ChatHomeScreenEmptyPreview() { ) } } + +/** + * A Codex model with the full effort range and its fast tier switched on, at phone width, to check + * that the effort and Fast chips sit side by side and the row still fits. + */ +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun ChatHomeScreenCodexFastPreview() { + val efforts = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") + AndCodeTheme { + ChatHomeScreen( + state = + ChatUiState( + backendName = "Codex", + isConnected = true, + selectedVariant = "ultra", + fastModeAvailable = true, + fastModeEnabled = true, + fastModeDescription = "1.5x speed, increased usage", + ), + providers = + listOf( + OpenCodeProvider( + id = "openai", + name = "OpenAI (Codex)", + models = + mapOf( + "gpt-6-astra" to + OpenCodeModel( + id = "gpt-6-astra", + providerId = "openai", + name = "GPT-6-Astra", + variants = efforts.associateWith { JsonPrimitive(it) }, + ), + ), + ), + ), + agents = emptyList(), + selectedProviderId = "openai", + selectedModelId = "gpt-6-astra", + selectedAgentId = null, + runtimeTargets = emptyList(), + selectedRuntimeId = null, + onSelectRuntime = {}, + onSelectModel = { _, _ -> }, + onSelectAgent = {}, + onSelectQuestionAnswer = { _, _, _ -> }, + onSubmitQuestion = {}, + onSendMessage = {}, + onPermission = { _, _, _ -> }, + onAbort = {}, + onMic = {}, + onNewChat = {}, + onOpenLocalSetup = {}, + onOpenRemoteSetup = {}, + onOpenDrawer = {}, + ) + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt index fc9c0290e..e8690950a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt @@ -508,6 +508,15 @@ data class ChatUiState( val autoAcceptPermissions: Boolean = false, val contextTokensUsed: Long = 0L, val selectedVariant: String? = null, + /** + * Whether the selected model offers Codex's fast service tier. Only Codex sets this, and only + * for a model that lists the tier, so the toggle is hidden everywhere else. + */ + val fastModeAvailable: Boolean = false, + /** Effective fast-mode state: the user's choice, or the model's own default when there is none. */ + val fastModeEnabled: Boolean = false, + /** The server's own description of the fast tier, for the toggle's subtitle. */ + val fastModeDescription: String? = null, val attachments: List = emptyList(), val imagePreviews: List = emptyList(), val selectedProviderId: String? = null, @@ -548,6 +557,24 @@ data class ChatUiState( val patchDiff: PatchDiffState? = null, ) +/** + * The `serviceTier` a prompt carries for a model whose fast tier is [fastTier], given the user's + * [fastMode] choice: null when the model has no fast tier or the choice still follows the model's + * default (Codex decides), the tier when fast mode is on, and `"default"` when it was turned off on a + * model that would otherwise run fast. + */ +internal fun codexServiceTier( + fastTier: String?, + fastMode: Boolean?, +): String? { + fastTier ?: return null + return when (fastMode) { + null -> null + true -> fastTier + false -> "default" + } +} + class ChatViewModel( private val backend: OpenCodeBackend? = null, private val eventFlow: Flow? = null, @@ -602,6 +629,13 @@ class ChatViewModel( */ private val videoNotSupportedMessage: String = "Video files cannot be sent directly. Remove the video and attach images instead.", + /** + * Persists the Codex fast-mode choice (null = follow the model default). Injected so the + * ViewModel never reaches for settings itself; absent in tests, where the state still updates. + */ + private val onCodexFastModeChanged: (Boolean?) -> Unit = {}, + /** The user's saved Codex fast-mode choice, read when the ViewModel starts. */ + private val initialCodexFastMode: Boolean? = null, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -643,6 +677,15 @@ class ChatViewModel( private var eventJob: Job? = null private var tts: TextToSpeech? = null private var contextLimit: Long = 0L + + /** Codex fast-mode user choice; null follows the selected model's own default. */ + private var codexFastMode: Boolean? = initialCodexFastMode + + /** The selected model's Codex fast tier id, or null when that model offers no fast mode. */ + private var selectedFastServiceTier: String? = null + + /** Whether the selected model's Codex fast tier is on by default. */ + private var selectedFastByDefault: Boolean = false private var recoveringTransiently = false private val streamedParts = mutableMapOf>() @@ -860,8 +903,13 @@ class ChatViewModel( modelId: String?, agentId: String?, contextLimit: Long = 0L, + fastServiceTier: String? = null, + fastByDefault: Boolean = false, + fastDescription: String? = null, ) { this.contextLimit = contextLimit + selectedFastServiceTier = fastServiceTier + selectedFastByDefault = fastByDefault _uiState.update { val modelChanged = providerId != it.selectedProviderId || modelId != it.selectedModelId it.copy( @@ -874,10 +922,42 @@ class ChatViewModel( // variant straight off whatever provider set it last, for any model whose id has no // effort baked in. selectedVariant = if (modelChanged) null else it.selectedVariant, + fastModeDescription = fastDescription, + ) + } + refreshFastMode() + } + + /** + * Derives the three fast-mode fields from the model and the user's choice. Fast mode exists only + * for a model that lists a fast tier, so every other model reads as unavailable. + */ + private fun refreshFastMode() { + val available = selectedFastServiceTier != null + _uiState.update { + it.copy( + fastModeAvailable = available, + fastModeEnabled = available && (codexFastMode ?: selectedFastByDefault), ) } } + /** + * Sets the Codex fast-mode choice and persists it. Passing null returns to the model's default. + */ + fun setFastMode(enabled: Boolean?) { + codexFastMode = enabled + onCodexFastModeChanged(enabled) + refreshFastMode() + } + + /** + * The `serviceTier` for a prompt on the selected model: null when fast mode is unavailable or + * still follows the default (Codex then chooses), the model's fast tier when the user turned it + * on, and `"default"` when they turned it off on a model that would otherwise be fast. + */ + private fun serviceTierForPrompt(): String? = codexServiceTier(selectedFastServiceTier, codexFastMode) + fun setAutoAcceptPermissions(enabled: Boolean) { _uiState.update { it.copy(autoAcceptPermissions = enabled) } } @@ -1442,6 +1522,7 @@ class ChatViewModel( agent = _uiState.value.selectedAgentId, variant = _uiState.value.selectedVariant, attachments = pendingAttachments, + serviceTier = serviceTierForPrompt(), ), ) runCatching { currentBackend.session(targetSessionId).title } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt index 8648b1835..72772d6f2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Psychology @@ -408,6 +409,16 @@ private fun ModelRow( modifier = Modifier.size(14.dp), ) } + // The same kind of hint for a Codex model with a fast tier, so the composer's Fast + // chip is not a surprise that only shows up after the model is picked. + if (model.codexFastServiceTier != null) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.cd_fast_mode), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } } Text( model.id, diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt index 0e39cdf7d..3a85a49b4 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt @@ -5,8 +5,10 @@ import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.ProviderCatalog import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull /** @@ -17,6 +19,9 @@ import kotlinx.serialization.json.contentOrNull object CodexModels { const val PROVIDER_ID = "openai" + /** Service-tier ids that mean "fast" when the server does not name the tier "Fast". */ + private val FAST_TIER_IDS = setOf("priority", "fast") + fun catalog( modelListResult: JsonObject, connected: Boolean, @@ -26,7 +31,19 @@ object CodexModels { entries.mapNotNull { entry -> val model = entry as? JsonObject ?: return@mapNotNull null val id = model.string("id") ?: return@mapNotNull null - id to OpenCodeModel(id = id, providerId = PROVIDER_ID, name = model.string("displayName") ?: id) + val fastTier = fastServiceTier(model) + id to + OpenCodeModel( + id = id, + providerId = PROVIDER_ID, + name = model.string("displayName") ?: id, + variants = reasoningEfforts(model), + codexFastServiceTier = fastTier?.first, + codexFastDescription = fastTier?.second, + codexFastByDefault = fastTier != null && model.string("defaultServiceTier") == fastTier.first, + codexDefaultEffort = model.string("defaultReasoningEffort")?.takeIf(String::isNotBlank), + codexDefaultServiceTier = model.string("defaultServiceTier")?.takeIf(String::isNotBlank), + ) }.toMap() val provider = OpenCodeProvider(id = PROVIDER_ID, name = "OpenAI (Codex)", models = models) return ProviderCatalog( @@ -35,8 +52,54 @@ object CodexModels { ) } + /** + * The id of the model Codex itself would use when none is named: the one `model/list` marks + * `isDefault`, else the first listed. Null when the list is empty. + */ + fun defaultModelId(modelListResult: JsonObject): String? { + val entries = (modelListResult["data"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: return null + val marked = entries.firstOrNull { (it["isDefault"] as? JsonPrimitive)?.booleanOrNull == true } + return (marked ?: entries.firstOrNull())?.string("id") + } + /** Codex has one agent - there is no separate plan/build split the way OpenCode's catalogue has. */ fun agents(): List = listOf(OpenCodeAgent(name = "codex", description = "Codex", mode = "primary", native = true)) + /** + * Reasoning efforts in the server's own order, keyed by the effort id the chat's thinking chip + * sends back as the turn's `effort`. Same shape as [ClaudeModels]' variants: the value is the + * id itself, so the chip reads the key and nothing else. + */ + private fun reasoningEfforts(model: JsonObject): Map { + val efforts = model["supportedReasoningEfforts"] as? JsonArray ?: return emptyMap() + return efforts.mapNotNull { entry -> + val effort = (entry as? JsonObject)?.string("reasoningEffort")?.takeIf(String::isNotBlank) + effort?.let { it to JsonPrimitive(it) } + }.toMap() + } + + /** + * The `(id, description)` of the fast service tier, or null when the model offers none. The tier + * is found by its name ("Fast") or its id (`priority`, `fast`); the deprecated + * `additionalSpeedTiers: ["fast"]` is the fallback for an older server that lists no tiers. + */ + private fun fastServiceTier(model: JsonObject): Pair? { + val tiers = model["serviceTiers"] as? JsonArray + val tier = + tiers?.firstNotNullOfOrNull { entry -> + val tierObject = entry as? JsonObject ?: return@firstNotNullOfOrNull null + val id = tierObject.string("id") ?: return@firstNotNullOfOrNull null + val name = tierObject.string("name") + if (name.equals("Fast", ignoreCase = true) || id in FAST_TIER_IDS) { + id to tierObject.string("description") + } else { + null + } + } + if (tier != null) return tier + val legacy = (model["additionalSpeedTiers"] as? JsonArray)?.any { (it as? JsonPrimitive)?.contentOrNull == "fast" } == true + return if (legacy) "fast" to null else null + } + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt index 87b4339f8..e20e821da 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt @@ -2,6 +2,7 @@ package com.yugahashimoto.andcode.runtime.local import com.yugahashimoto.andcode.core.api.McpServer import com.yugahashimoto.andcode.core.api.OpenCodeEvent +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeMessage import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeTime @@ -369,14 +370,11 @@ class CodexRuntime( sessionId: String, prompt: String, model: String?, + effort: String? = null, + serviceTier: String? = null, attachments: List = emptyList(), ) { - val params = - buildJsonObject { - put("threadId", JsonPrimitive(sessionId)) - put("input", codexTurnInput(prompt, attachments)) - model?.takeIf(String::isNotBlank)?.let { put("model", JsonPrimitive(it)) } - } + val params = codexTurnStartParams(sessionId, prompt, model, effort, serviceTier, attachments) runCatching { call("turn/start", params) } .onSuccess { result -> // Recorded by turn id, not just session id, so onServerEnded can evict exactly this @@ -671,6 +669,62 @@ internal fun parseVersionLine(line: String): String = line.trim().removePrefix(" */ internal fun hasSignedInAccount(accountRead: JsonObject): Boolean = accountRead["account"] is JsonObject +/** The explicit `effort` and `serviceTier` one turn is sent with. `effort` is null only when unknown. */ +internal data class CodexTurnSettings( + val effort: String?, + val serviceTier: String, +) + +/** + * Resolves what one turn is sent with. The app-server applies `effort` and `serviceTier` to later + * turns too, so an omitted value would inherit the previous turn's (the last model's effort, or a + * fast tier left on after switching to a model without one). Every turn therefore states both: + * + * - [variant] (the chat's effort choice) wins; otherwise the model's own default effort. Omitted + * only when neither is known. + * - [requestedTier] wins when set, including an explicit `"default"`; otherwise the model's default + * tier, and `"default"` (standard speed) when the model names none. + * + * A null [model] means the catalogue has no entry for the model, so there is no default to fall back + * on: the tier is `"default"` and the effort is left to the request alone. + */ +internal fun resolveCodexTurnSettings( + variant: String?, + requestedTier: String?, + model: OpenCodeModel?, +): CodexTurnSettings = + CodexTurnSettings( + effort = variant?.takeIf(String::isNotBlank) ?: model?.codexDefaultEffort?.takeIf(String::isNotBlank), + serviceTier = + requestedTier?.takeIf(String::isNotBlank) + ?: model?.codexDefaultServiceTier?.takeIf(String::isNotBlank) + ?: DEFAULT_SERVICE_TIER, + ) + +/** The app-server's explicit "standard speed" tier, which turns off a fast tier left on earlier. */ +internal const val DEFAULT_SERVICE_TIER = "default" + +/** + * The `turn/start` params for one prompt. [effort] and [serviceTier] are sent only when non-blank: + * callers that want the explicit-every-turn behaviour resolve them with [resolveCodexTurnSettings] + * first, so this builder just writes what it is given. + */ +internal fun codexTurnStartParams( + sessionId: String, + prompt: String, + model: String?, + effort: String?, + serviceTier: String?, + attachments: List, +): JsonObject = + buildJsonObject { + put("threadId", JsonPrimitive(sessionId)) + put("input", codexTurnInput(prompt, attachments)) + model?.takeIf(String::isNotBlank)?.let { put("model", JsonPrimitive(it)) } + effort?.takeIf(String::isNotBlank)?.let { put("effort", JsonPrimitive(it)) } + serviceTier?.takeIf(String::isNotBlank)?.let { put("serviceTier", JsonPrimitive(it)) } + } + /** * The `turn/start` `input` array for [prompt] plus any image [attachments]. * diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt index 38f0e653b..25d82d621 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt @@ -6,6 +6,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeEvent import com.yugahashimoto.andcode.core.api.OpenCodeFileContent import com.yugahashimoto.andcode.core.api.OpenCodeFileNode import com.yugahashimoto.andcode.core.api.OpenCodeHealth +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeMessage import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession @@ -120,15 +121,32 @@ class CodexTarget( override suspend fun listMessages(sessionId: String): List = withContext(Dispatchers.IO) { runtime.listMessages(sessionId) } + /** Each model's defaults from the last catalogue this target built, so a turn can state them. */ + @Volatile private var knownModels: Map = emptyMap() + + /** The `isDefault` model from the last `model/list`, for a turn that names no model. */ + @Volatile private var knownDefaultModelId: String? = null + + /** Whether [sendMessage] already re-read `model/list` successfully for a model it did not know. */ + @Volatile private var modelsRefreshed = false + override suspend fun listProviders(): ProviderCatalog = withContext(Dispatchers.IO) { // Two independent app-server round trips: run them concurrently rather than doubling // the latency of a call this app makes on every runtime selection and catalogue refresh. val models = async { runtime.modelList() } val signedIn = async { runtime.isSignedIn() } - CodexModels.catalog(models.await(), connected = signedIn.await()) + val listing = models.await() + rememberModels(listing) + CodexModels.catalog(listing, connected = signedIn.await()) } + private fun rememberModels(listing: JsonObject) { + val catalog = CodexModels.catalog(listing, connected = false) + knownModels = catalog.all.single().models + knownDefaultModelId = CodexModels.defaultModelId(listing) + } + override suspend fun listAgents(): List = CodexModels.agents() // Index 0 is the ChatGPT browser sign-in, 1 the API key: authorizeProvider and setProviderApiKey @@ -229,9 +247,31 @@ class CodexTarget( sessionId: String, request: PromptRequest, ) { - withContext(Dispatchers.IO) { runtime.send(sessionId, request.text, request.modelId, request.attachments) } + withContext(Dispatchers.IO) { + val requestedModelId = request.modelId?.takeIf(String::isNotBlank) + var model = resolveKnownModel(requestedModelId) + if (model == null && !modelsRefreshed) { + // Once per target: the catalogue may simply not have been built yet (a turn sent + // before the picker loaded), and that read also fills in the default model id. Only a + // successful read counts, so a server still starting up is asked again next turn. + runCatching { rememberModels(runtime.modelList()) }.onSuccess { modelsRefreshed = true } + model = resolveKnownModel(requestedModelId) + } + val settings = resolveCodexTurnSettings(request.variant, request.serviceTier, model) + runtime.send( + sessionId = sessionId, + prompt = request.text, + model = request.modelId, + effort = settings.effort, + serviceTier = settings.serviceTier, + attachments = request.attachments, + ) + } } + /** The cached model for [modelId], or for Codex's default model when the turn names none. */ + private fun resolveKnownModel(modelId: String?): OpenCodeModel? = (modelId ?: knownDefaultModelId)?.let(knownModels::get) + override suspend fun abortSession(sessionId: String): Boolean = withContext(Dispatchers.IO) { runtime.abort(sessionId) } override suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) { runtime.deleteSession(sessionId) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index ba6e60f34..033f6d93a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -310,6 +310,8 @@ fun AndCodeApp( onApplySystemPrompt = { sessionId, presetId -> app.claudeCodeController.selectSystemPrompt(presetId, sessionId) }, + onCodexFastModeChanged = app.preferences::setCodexFastMode, + initialCodexFastMode = app.settings.codexFastMode, onSessionAborted = { sessionId -> app.activityRepository.markSessionAborted(sessionId) // Stopping the run answers the "this run has gone quiet" notice. @@ -614,6 +616,9 @@ fun AndCodeApp( preferences.modelId?.takeIf { model != null }, preferences.agentId?.takeIf { id -> settingsState.agents.any { it.name == id } }, model?.limit?.context ?: 0L, + fastServiceTier = model?.codexFastServiceTier, + fastByDefault = model?.codexFastByDefault == true, + fastDescription = model?.codexFastDescription, ) } @@ -1131,6 +1136,7 @@ fun AndCodeApp( onSelectAgent = settingsViewModel::selectAgent, selectedVariant = chatState.selectedVariant, onSelectVariant = chatViewModel::selectVariant, + onToggleFastMode = { enabled -> chatViewModel.setFastMode(enabled) }, onAttach = { attachmentLauncher.launch("*/*") }, onRemoveAttachment = chatViewModel::removeAttachment, onImageAttachment = { bitmap -> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d6d73db83..416c8f3f3 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -481,6 +481,10 @@ مرتفع مرتفع جدًا الحد الأقصى + إيقاف + الحد الأدنى + فائق + سريع الموجّه بناء تخطيط @@ -759,6 +763,8 @@ Camera Gallery Thinking + الوضع السريع + الوضع السريع: %1$s موجّه النظام Agent mode Updating diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ffa069283..b6f12b3a4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -475,6 +475,10 @@ Alto Muy alto Máximo + Desactivado + Mínimo + Ultra + Rápido Prompt Construir Planificar @@ -750,6 +754,8 @@ Camera Gallery Thinking + Modo rápido + Modo rápido: %1$s Prompt del sistema Agent mode Updating diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7514301f2..15f1b5f62 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -475,6 +475,10 @@ Élevé Très élevé Maximum + Désactivé + Minimum + Ultra + Rapide Invite Construire Planifier @@ -750,6 +754,8 @@ Camera Gallery Thinking + Mode rapide + Mode rapide : %1$s Invite système Agent mode Updating diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index da35ffb9e..b004b80e3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -473,6 +473,10 @@ 高 最高 Max + オフ + 最低 + Ultra + 高速 プロンプト ビルド 計画 @@ -757,6 +761,8 @@ カメラ ギャラリー 思考中 + 高速モード + 高速モード: %1$s システムプロンプト エージェントモード 更新中 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 05001cc7c..e697076d9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -475,6 +475,10 @@ Alto Extra alto Máximo + Desativado + Mínimo + Ultra + Rápido Prompt Construir Planejar @@ -750,6 +754,8 @@ Camera Gallery Thinking + Modo rápido + Modo rápido: %1$s Prompt do sistema Agent mode Updating diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c86a6b7fe..5d72e7eca 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -477,6 +477,10 @@ Высокий Очень высокий Максимум + Выкл. + Минимум + Ультра + Быстрый Промпт Сборка План @@ -753,6 +757,8 @@ Camera Gallery Thinking + Быстрый режим + Быстрый режим: %1$s Системный промпт Agent mode Updating diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fe5b5a2ea..e7ffc0d42 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -471,6 +471,10 @@ 高 超高 最高 + 关闭 + 极低 + 极致 + 快速 提示词 构建 计划 @@ -744,6 +748,8 @@ Camera Gallery Thinking + 快速模式 + 快速模式:%1$s 系统提示词 Agent mode Updating diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2fd0be3f4..0c8564bdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -461,6 +461,10 @@ High Extra High Max + Off + Minimal + Ultra + Fast Prompt Build Plan @@ -748,6 +752,8 @@ Camera Gallery Thinking + Fast mode + Fast mode: %1$s System prompt Agent mode Updating diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt index a7839aa32..6859fc8b8 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt @@ -1829,6 +1829,110 @@ class ChatViewModelTest { assertEquals("high", viewModel.uiState.value.selectedVariant) } + @Test + fun `a model with a fast tier shows fast mode off when the user has no choice`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend()) + advanceUntilIdle() + + viewModel.selectConfiguration("openai", "gpt-6-astra", agentId = null, fastServiceTier = "priority") + + assertTrue(viewModel.uiState.value.fastModeAvailable) + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `a model that defaults to fast reads as enabled until the user turns it off`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend()) + advanceUntilIdle() + + viewModel.selectConfiguration( + "openai", + "gpt-6-astra", + agentId = null, + fastServiceTier = "priority", + fastByDefault = true, + ) + assertTrue(viewModel.uiState.value.fastModeEnabled) + + viewModel.setFastMode(false) + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `a model without a fast tier reports fast mode unavailable whatever the choice`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend()) + advanceUntilIdle() + + viewModel.setFastMode(true) + viewModel.selectConfiguration("openai", "plain", agentId = null) + + assertFalse(viewModel.uiState.value.fastModeAvailable) + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `setting fast mode invokes the persistence callback`() = + runTest(dispatcher) { + val saved = mutableListOf() + val viewModel = ChatViewModel(FakeBackend(), onCodexFastModeChanged = { saved += it }) + advanceUntilIdle() + + viewModel.setFastMode(true) + viewModel.setFastMode(null) + + assertEquals(listOf(true, null), saved) + } + + @Test + fun `a saved fast-mode choice is applied when the view model starts`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend(), initialCodexFastMode = false) + advanceUntilIdle() + + viewModel.selectConfiguration("openai", "gpt-6-astra", agentId = null, fastServiceTier = "priority", fastByDefault = true) + + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `a prompt carries the service tier the fast choice maps to`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + advanceUntilIdle() + viewModel.selectConfiguration("openai", "gpt-6-astra", agentId = null, fastServiceTier = "priority") + + viewModel.setFastMode(true) + viewModel.sendMessage("on") + advanceUntilIdle() + viewModel.setFastMode(false) + viewModel.sendMessage("off") + advanceUntilIdle() + viewModel.setFastMode(null) + viewModel.sendMessage("follow") + advanceUntilIdle() + + assertEquals(listOf("priority", "default", null), backend.sentPrompts.map { it.second.serviceTier }) + } + + @Test + fun `a model without a fast tier never sends a service tier`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + advanceUntilIdle() + viewModel.selectConfiguration("claude-code", "sonnet", agentId = null) + + viewModel.setFastMode(true) + viewModel.sendMessage("hello") + advanceUntilIdle() + + assertNull(backend.sentPrompts.single().second.serviceTier) + } + @Test fun `dismissing the todo bar records the entry so it stays closed`() = runTest(dispatcher) { diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt new file mode 100644 index 000000000..a5cf670bd --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt @@ -0,0 +1,33 @@ +package com.yugahashimoto.andcode.feature.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The chat's fast-mode mapping ([codexServiceTier]): the user's choice becomes the `serviceTier` a + * prompt carries. A null choice sends nothing, so the Codex layer falls back to the model's own + * default; `"default"` is the explicit standard-speed request. + */ +class CodexServiceTierTest { + @Test + fun `a model without a fast tier never sends one`() { + assertEquals(null, codexServiceTier(fastTier = null, fastMode = true)) + assertEquals(null, codexServiceTier(fastTier = null, fastMode = false)) + assertEquals(null, codexServiceTier(fastTier = null, fastMode = null)) + } + + @Test + fun `following the model default sends nothing`() { + assertEquals(null, codexServiceTier(fastTier = "priority", fastMode = null)) + } + + @Test + fun `turning fast mode on sends the model's fast tier`() { + assertEquals("priority", codexServiceTier(fastTier = "priority", fastMode = true)) + } + + @Test + fun `turning fast mode off sends default`() { + assertEquals("default", codexServiceTier(fastTier = "priority", fastMode = false)) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt index 98ced9079..52de29775 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt @@ -3,6 +3,8 @@ package com.yugahashimoto.andcode.runtime.local import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -31,4 +33,103 @@ class CodexModelsTest { assertTrue(catalog.connected.isEmpty()) } + + /** Shape copied from a live `codex app-server` 0.163.0 `model/list`. */ + private val effortAndFastJson = + """{"data":[{"id":"gpt-6-astra","model":"gpt-6-astra","displayName":"GPT-6-Astra", + "supportedReasoningEfforts":[ + {"reasoningEffort":"low","description":"Fast responses"}, + {"reasoningEffort":"medium","description":"Balanced"}, + {"reasoningEffort":"high","description":"Thorough"}, + {"reasoningEffort":"xhigh","description":"Deepest"}, + {"reasoningEffort":"max","description":"Maximum"}, + {"reasoningEffort":"ultra","description":"Ultra"}], + "defaultReasoningEffort":"medium", + "serviceTiers":[{"id":"priority","name":"Fast","description":"1.5x speed, increased usage"}], + "defaultServiceTier":"priority", + "additionalSpeedTiers":["fast"]}]}""" + + @Test + fun `reasoning efforts become variants in the server's order`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(effortAndFastJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals(listOf("low", "medium", "high", "xhigh", "max", "ultra"), model.variants.keys.toList()) + assertEquals("low", (model.variants.getValue("low") as kotlinx.serialization.json.JsonPrimitive).content) + } + + @Test + fun `a model with no reasoning efforts has no variants`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(modelListJson).jsonObject, connected = true) + + assertTrue(catalog.all.single().models.getValue("gpt-6-astra").variants.isEmpty()) + } + + @Test + fun `the priority tier named Fast is the fast tier and is on by default`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(effortAndFastJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals("priority", model.codexFastServiceTier) + assertEquals("1.5x speed, increased usage", model.codexFastDescription) + assertTrue(model.codexFastByDefault) + } + + @Test + fun `a model that does not default to fast has the tier but is not fast by default`() { + val notDefault = effortAndFastJson.replace(""""defaultServiceTier":"priority",""", """"defaultServiceTier":null,""") + val catalog = CodexModels.catalog(json.parseToJsonElement(notDefault).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals("priority", model.codexFastServiceTier) + assertFalse(model.codexFastByDefault) + } + + @Test + fun `the model's default effort and default tier are recorded`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(effortAndFastJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals("medium", model.codexDefaultEffort) + assertEquals("priority", model.codexDefaultServiceTier) + } + + @Test + fun `a model that names no default tier records none`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(modelListJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertNull(model.codexDefaultEffort) + assertNull(model.codexDefaultServiceTier) + } + + @Test + fun `the default model is the one marked isDefault, else the first`() { + val marked = + """{"data":[{"id":"a"},{"id":"b","isDefault":true},{"id":"c"}]}""" + val unmarked = """{"data":[{"id":"a"},{"id":"b"}]}""" + + assertEquals("b", CodexModels.defaultModelId(json.parseToJsonElement(marked).jsonObject)) + assertEquals("a", CodexModels.defaultModelId(json.parseToJsonElement(unmarked).jsonObject)) + assertNull(CodexModels.defaultModelId(json.parseToJsonElement("""{"data":[]}""").jsonObject)) + } + + @Test + fun `a model without a fast tier has no fast fields`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(modelListJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertNull(model.codexFastServiceTier) + assertNull(model.codexFastDescription) + assertFalse(model.codexFastByDefault) + } + + @Test + fun `the legacy additionalSpeedTiers fast entry is used when no tier is listed`() { + val legacy = + """{"data":[{"id":"m","displayName":"M","additionalSpeedTiers":["fast"]}]}""" + val catalog = CodexModels.catalog(json.parseToJsonElement(legacy).jsonObject, connected = true) + + assertEquals("fast", catalog.all.single().models.getValue("m").codexFastServiceTier) + } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt new file mode 100644 index 000000000..951f5550a --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt @@ -0,0 +1,123 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.api.OpenCodeModel +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the `turn/start` params [codexTurnStartParams] builds and the per-turn resolution + * [resolveCodexTurnSettings]. The builder writes only the values it is given: a blank or null + * `effort` or `serviceTier` is omitted. Callers resolve first, so every real turn states both. + */ +class CodexTurnStartParamsTest { + private fun params( + effort: String? = null, + serviceTier: String? = null, + model: String? = "gpt-6-astra", + ): JsonObject = codexTurnStartParams("thr_1", "hello", model, effort, serviceTier, emptyList()) + + @Test + fun `effort and service tier are sent when set`() { + val result = params(effort = "high", serviceTier = "priority") + + assertEquals("high", result.getValue("effort").jsonPrimitive.content) + assertEquals("priority", result.getValue("serviceTier").jsonPrimitive.content) + } + + @Test + fun `effort and service tier are omitted when unset`() { + val result = params() + + assertFalse(result.containsKey("effort")) + assertFalse(result.containsKey("serviceTier")) + } + + @Test + fun `blank effort and service tier are omitted`() { + val result = params(effort = " ", serviceTier = "") + + assertFalse(result.containsKey("effort")) + assertFalse(result.containsKey("serviceTier")) + } + + @Test + fun `default is passed through to force standard speed`() { + val result = params(serviceTier = "default") + + assertEquals("default", result.getValue("serviceTier").jsonPrimitive.content) + } + + @Test + fun `the thread, input and model are always present`() { + val result = params(effort = "low") + + assertEquals("thr_1", result.getValue("threadId").jsonPrimitive.content) + assertTrue(result.containsKey("input")) + assertEquals("gpt-6-astra", result.getValue("model").jsonPrimitive.content) + } + + @Test + fun `a blank model is omitted like the other optional fields`() { + val result = params(model = " ") + + assertFalse(result.containsKey("model")) + } + + private val model = + OpenCodeModel( + id = "gpt-6-astra", + providerId = CodexModels.PROVIDER_ID, + codexDefaultEffort = "medium", + codexDefaultServiceTier = "priority", + ) + + @Test + fun `no variant falls back to the model's default effort`() { + assertEquals("medium", resolveCodexTurnSettings(null, null, model).effort) + } + + @Test + fun `an explicit variant wins over the model default`() { + assertEquals("ultra", resolveCodexTurnSettings("ultra", null, model).effort) + } + + @Test + fun `no requested tier falls back to the model's default tier`() { + assertEquals("priority", resolveCodexTurnSettings(null, null, model).serviceTier) + } + + @Test + fun `no requested tier and no model default sends default`() { + val plain = OpenCodeModel(id = "plain", providerId = CodexModels.PROVIDER_ID) + + assertEquals(DEFAULT_SERVICE_TIER, resolveCodexTurnSettings(null, null, plain).serviceTier) + assertNull(resolveCodexTurnSettings(null, null, plain).effort) + } + + @Test + fun `an explicit default or tier passes through unchanged`() { + assertEquals("default", resolveCodexTurnSettings(null, "default", model).serviceTier) + assertEquals("priority", resolveCodexTurnSettings(null, "priority", model).serviceTier) + } + + @Test + fun `an unknown model omits the effort and sends default`() { + val settings = resolveCodexTurnSettings(null, null, null) + + assertNull(settings.effort) + assertEquals("default", settings.serviceTier) + } + + @Test + fun `an unknown model still honours an explicit variant and tier`() { + val settings = resolveCodexTurnSettings("high", "priority", null) + + assertEquals("high", settings.effort) + assertEquals("priority", settings.serviceTier) + } +} diff --git a/docs/CODEX.md b/docs/CODEX.md index f39b8f9a4..03be875e9 100644 --- a/docs/CODEX.md +++ b/docs/CODEX.md @@ -209,6 +209,42 @@ non-functional, second sandboxing layer. live, signed-in account. `CodexItemParser` maps the image back out of the echoed `userMessage` content into a `file` part, so the attachment shows in the transcript and survives a reload. +## Reasoning effort and Fast mode + +Both come from `model/list` (verified live against `codex-cli 0.163.0`, the first version with these +fields; older Codex versions omit them and the models simply show no effort or fast control): + +- **Reasoning effort**: each entry of `supportedReasoningEfforts` becomes a model variant, in the + server's order, so the chat's thinking chip lists them. Efforts seen so far: `low`, `medium`, `high`, + `xhigh`, `max`, `ultra`, plus `none` and `minimal` documented by the protocol. Each has a chip label + (`none` reads "Off"); an effort the app does not know yet shows its raw id capitalized. Fast mode is + the bolt "Fast" chip beside it, shown only for a model with a fast tier; the tier's server description + is its screen-reader text. +- **Fast mode**: a model whose `serviceTiers` lists the tier named "Fast" (id `priority`), or the + deprecated `additionalSpeedTiers: ["fast"]`, gets `codexFastServiceTier`. The user's global choice + (`codexFastMode`, persisted; unset follows the model) maps to a tier as `codexServiceTier`: unset + requests nothing, on requests the tier id, off requests `"default"` for standard speed. Some models + default to the fast tier (`defaultServiceTier`), which the UI state reports as on until turned off. +- **Every turn states its settings.** The app-server applies `effort` and `serviceTier` to subsequent + turns as well as the current one, so an omitted value would inherit the previous turn's: a `high` left + on after going back to the default, an `ultra` carried onto a different model, or a fast tier kept + after switching to a model without one. `CodexTarget.sendMessage` therefore always sends both, resolved + by `resolveCodexTurnSettings`: + - `effort` is the chat's chosen variant, else the model's `defaultReasoningEffort` + (`codexDefaultEffort`). It is omitted only when neither is known, in which case the thread keeps + the previous turn's effort (every model in 0.163.0's `model/list` has a default, so this is rare). + - `serviceTier` is the requested tier, else the model's `defaultServiceTier` (`codexDefaultServiceTier`), + else `"default"`. + - The model is the one named on the request, else the one `model/list` marks `isDefault` (first listed + if none is marked). Its defaults come from the catalogue the target last built; a model it has not + seen triggers a best-effort `model/list` re-read, retried on later turns until one read succeeds. + If the model is still unknown, the turn sends `serviceTier: "default"` and omits `effort`. + - `isDefault: boolean` is part of the generated `v2/Model.ts` in both 0.155.1 and 0.163.0. + +The fast fields are named `codexFast*` so they cannot collide with an OpenCode key, and they stay +serialized so the disk catalogue cache keeps them. `serviceTier` on `PromptRequest` is built field by +field for OpenCode and never sent there. + ## Testing `CodexJsonRpcClientTest`, `CodexItemParserTest` and `CodexModelsTest` use JSON fixtures taken From bea27fae4b4ad1c93f6aaee06e0382558248e882 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 14:35:03 +0800 Subject: [PATCH 2/2] style(codex): sort imports --- .../com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt | 2 +- .../java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt index e20e821da..efb5aae4f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt @@ -2,8 +2,8 @@ package com.yugahashimoto.andcode.runtime.local import com.yugahashimoto.andcode.core.api.McpServer import com.yugahashimoto.andcode.core.api.OpenCodeEvent -import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeMessage +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeTime import com.yugahashimoto.andcode.core.api.PermissionRequest diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt index 25d82d621..0f744e9b9 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt @@ -6,8 +6,8 @@ import com.yugahashimoto.andcode.core.api.OpenCodeEvent import com.yugahashimoto.andcode.core.api.OpenCodeFileContent import com.yugahashimoto.andcode.core.api.OpenCodeFileNode import com.yugahashimoto.andcode.core.api.OpenCodeHealth -import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeMessage +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.PromptRequest