Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
283a135
feat(providers): fetch a custom provider's models from <baseURL>/models
doublepi123 Oct 15, 2026
c1c30cf
fix(providers): address review on custom provider model fetch
doublepi123 Oct 15, 2026
79edbd3
fix(providers): rethrow cancellation in custom provider submit too
doublepi123 Oct 15, 2026
ae70a43
fix(providers): preserve existing models during runtime startup
doublepi123 Oct 15, 2026
a0341ad
fix(providers): reject scalar model-list responses
doublepi123 Oct 15, 2026
a1e4aeb
feat(chat): offer plugin-defined primary agents in the mode picker
doublepi123 Oct 15, 2026
625c259
refactor(chat): drop the unreachable build fallback in defaultAgentName
doublepi123 Oct 15, 2026
ad830dd
feat: run guest startup commands on local runtime start
yuga-hashimoto Oct 15, 2026
3cde2f9
fix: treat a blank checkCommand as no check
yuga-hashimoto Oct 15, 2026
e7c4578
feat(opencode): speak the OpenCode V2 server API alongside V1
doublepi123 Oct 15, 2026
c3eef85
fix(opencode): confirm V1 servers with the profile's username
doublepi123 Oct 15, 2026
5a62783
fix(opencode): keep the V2 agent mapping independent of hidden agents
doublepi123 Oct 15, 2026
4825977
fix(opencode): keep proxied V1 servers reachable and gate V2 MCP OAuth
doublepi123 Oct 15, 2026
23b8518
Merge pull request #383 from yuga-hashimoto/feat/startup-commands
yuga-hashimoto Oct 15, 2026
4a0f7d6
Merge pull request #381 from doublepi123/fix/preserve-custom-provider…
yuga-hashimoto Oct 16, 2026
0f7d0e8
Merge pull request #382 from doublepi123/feat/upstream-plugin-primary…
yuga-hashimoto Oct 16, 2026
1fe752b
Merge pull request #380 from doublepi123/feat/custom-provider-fetch-m…
yuga-hashimoto Oct 16, 2026
1c5acec
Merge pull request #384 from doublepi123/feat/upstream-opencode-v2
yuga-hashimoto Oct 16, 2026
655b843
feat(chat): show live token speed beside the context meter
yuga-hashimoto Oct 16, 2026
223972d
fix(chat): avoid showing a rounded-down 0.0 tok/s
yuga-hashimoto Oct 16, 2026
64e90a9
Merge pull request #385 from yuga-hashimoto/token-speed-display
yuga-hashimoto Oct 16, 2026
96d628e
Merge upstream/main into fork main
doublepi123 Oct 16, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import com.yugahashimoto.andcode.runtime.local.LocalRuntimeReleaseClient
import com.yugahashimoto.andcode.runtime.local.LocalRuntimeServiceController
import com.yugahashimoto.andcode.runtime.local.LocalRuntimeTarget
import com.yugahashimoto.andcode.runtime.local.LocalRuntimeUpdater
import com.yugahashimoto.andcode.runtime.local.StartupCommandsRunner
import com.yugahashimoto.andcode.runtime.local.SystemPromptStore
import com.yugahashimoto.andcode.runtime.local.VerifiedRuntimeDownloader
import com.yugahashimoto.andcode.runtime.local.applyOpenCodeSystemPrompt
Expand Down Expand Up @@ -287,6 +288,13 @@ class AndCodeApplication : Application() {
accessCoordinator = accessCoordinator,
)
localRuntimeInstaller = installer
// Guest startup commands (issue #379): read from the workspace on every runtime start,
// launched detached so they can neither block nor fail the start itself.
val startupCommandsRunner =
StartupCommandsRunner(
runtimeDirectory = runtimeDirectory,
githubToken = { settings.githubToken },
)
val launcher =
LocalRuntimeProcessLauncher(
runtimeDirectory = runtimeDirectory,
Expand All @@ -300,6 +308,7 @@ class AndCodeApplication : Application() {
if (settings.githubToken.isNullOrBlank()) helper.remove() else helper.install()
}
}
runCatching { startupCommandsRunner.schedule(installed) }
},
)
commandRunner =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,17 @@ class OpenCodeV2ApiClient(
)
}

/**
* Internal V2 agents (compaction, title, summary) are reported as `mode: primary` with
* `hidden: true`; the app's agent model has no hidden flag yet, so they are dropped here to
* keep them out of the mode picker and settings.
*/
override suspend fun agents(): List<OpenCodeAgent> =
getData<List<V2Agent>>("api/agent").map { agent ->
getData<List<V2Agent>>("api/agent").filterNot { it.hidden }.map { agent ->
OpenCodeAgent(
name = agent.id,
description = agent.description,
mode = agent.mode,
hidden = agent.hidden,
)
}

Expand Down Expand Up @@ -527,6 +531,7 @@ class OpenCodeV2ApiClient(
directory: String?,
): Boolean {
sendVoid(requestBuilder("api/session/${encodePath(sessionId)}").delete())
sessionLocks.remove(sessionId)
return true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import okhttp3.Credentials
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException

/** Which HTTP API a server speaks. V2 servers answer under the `api/` prefix; V1 servers answer the legacy routes. */
enum class OpenCodeProtocol { V1, V2 }
Expand Down Expand Up @@ -298,24 +299,43 @@ class ProtocolDetectingOpenCodeApi(
*
* V1 servers answer `api/info` with the single-page-app HTML, which is not conclusive by itself.
* V1 is only confirmed when `global/health` succeeds, so an outage or proxy error cannot be
* mistaken for a V1 server. Transient statuses (5xx, 408, 429) are thrown and not cached.
* mistaken for a V1 server.
*
* Older V1 servers, and V1 builds without an embedded web UI, proxy unknown paths such as
* `api/info` to app.opencode.ai, so a 5xx or a timeout there can be a perfectly healthy V1
* server whose upstream web host is unreachable. Those failures still try `global/health`, but
* only a 1.x version on it is accepted as V1: a V2 server answers that path with HTML, so its
* transient failures keep throwing and are never cached as V1.
*/
private suspend fun probe(): Probe =
withContext(Dispatchers.IO) {
val info = fetchStatus("api/info")
val info =
try {
fetchStatus("api/info")
} catch (error: IOException) {
return@withContext confirmLegacyMajorOne() ?: throw error
}
when {
info.code in 200..299 -> versionOf(info.body)?.let { Probe.V2(it) } ?: confirmLegacy()
info.code == 401 && isUnauthorizedTag(info.body) -> Probe.V2Unauthorized
info.code == 401 || info.code == 403 || info.code == 404 || info.code in 300..399 -> confirmLegacy()
else -> throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})")
else ->
confirmLegacyMajorOne()
?: throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})")
}
}

/** [confirmLegacy] for an inconclusive `api/info`: a 1.x server, or null when that is not proven. */
private fun confirmLegacyMajorOne(): Probe.Legacy? =
runCatching { confirmLegacy() }
.getOrNull()
?.takeIf { legacy -> legacy.health.version.trim().removePrefix("v").substringBefore('.') == "1" }

/**
* Confirms a V1 server with `global/health`; any non-success is not a V1 verdict. The body is
* kept so that [health] can return it without a second request.
*/
private fun confirmLegacy(): Probe {
private fun confirmLegacy(): Probe.Legacy {
// A V1 server may run with a custom OPENCODE_SERVER_USERNAME, which the V1 client honours,
// so the V1 confirmation authenticates as the profile does rather than as V2's fixed user.
val response = fetchStatus("global/health", username = profile.username.ifBlank { OpenCodeV2ApiClient.USERNAME })
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.yugahashimoto.andcode.runtime.local.LocalRuntimeReleaseClient
import com.yugahashimoto.andcode.runtime.local.LocalRuntimeServiceController
import com.yugahashimoto.andcode.runtime.local.LocalRuntimeTarget
import com.yugahashimoto.andcode.runtime.local.LocalRuntimeUpdater
import com.yugahashimoto.andcode.runtime.local.StartupCommandsRunner
import com.yugahashimoto.andcode.runtime.local.VerifiedRuntimeDownloader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -88,6 +89,13 @@ val appModule =
val providerCredentials: LocalProviderCredentialStore = get()
val customProviders: CustomProviderStore = get()
val runtimeDirectory: File = get()
// Guest startup commands (issue #379): read from the workspace on every runtime start,
// launched detached so they can neither block nor fail the start itself.
val startupCommandsRunner =
StartupCommandsRunner(
runtimeDirectory = runtimeDirectory,
githubToken = { settings.githubToken },
)
LocalRuntimeProcessLauncher(
runtimeDirectory = runtimeDirectory,
portProbe = LocalRuntimeManager::defaultPortProbe,
Expand All @@ -100,6 +108,7 @@ val appModule =
if (settings.githubToken.isNullOrBlank()) helper.remove() else helper.install()
}
}
runCatching { startupCommandsRunner.schedule(installed) }
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.yugahashimoto.andcode.feature.activity.ActivityViewModel
import com.yugahashimoto.andcode.feature.chat.ChatViewModel
import com.yugahashimoto.andcode.feature.settings.SettingsViewModel
import com.yugahashimoto.andcode.feature.workspace.WorkspaceViewModel
import com.yugahashimoto.andcode.runtime.local.CustomProviderModelFetcher
import kotlinx.coroutines.flow.first
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
Expand Down Expand Up @@ -53,6 +54,13 @@ val viewModelModule =
androidContext().getString(com.yugahashimoto.andcode.R.string.provider_disconnect_rejected),
customProviderInvalidMessage =
androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_invalid),
modelFetcher = CustomProviderModelFetcher(get()),
customProviderFetchNeedsBaseUrlMessage =
androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_needs_base_url),
customProviderFetchEmptyMessage =
androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_empty),
customProviderFetchFailedMessage =
androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_failed),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ fun ChatHomeScreen(
.firstOrNull { it.id == selectedProviderId }
?.models?.get(selectedModelId)
?.limit?.context ?: 0L,
tokensPerSecond = state.connectionQuality?.tokensPerSecond ?: 0.0,
showSlashCommands = showSlashCommands,
slashCommands = state.slashCommands,
slashSkills = state.slashSkills,
Expand Down Expand Up @@ -1257,6 +1258,7 @@ private fun ChatComposer(
enterToSend: Boolean,
contextTokensUsed: Long,
contextLimit: Long,
tokensPerSecond: Double,
showSlashCommands: Boolean,
slashCommands: List<OpenCodeCommand>,
slashSkills: List<OpenCodeSkill>,
Expand Down Expand Up @@ -1630,6 +1632,9 @@ private fun ChatComposer(
contextLimit = contextLimit,
)
}
if (isRunning && tokensPerSecond >= 0.05) {
CompactTokenSpeed(tokensPerSecond)
}
}
}
}
Expand Down Expand Up @@ -2167,6 +2172,20 @@ private fun formatTokenCount(tokens: Long): String =
else -> tokens.toString()
}

@Composable
private fun CompactTokenSpeed(tokensPerSecond: Double) {
Text(
text = stringResource(R.string.chat_token_speed, formatTokenRate(tokensPerSecond)),
style = MaterialTheme.typography.labelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}

private fun formatTokenRate(tokensPerSecond: Double): String =
if (tokensPerSecond >= 10.0) "%.0f".format(tokensPerSecond) else "%.1f".format(tokensPerSecond)

@Preview(showBackground = true)
@Composable
private fun ChatHomeScreenEmptyPreview() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,27 @@ package com.yugahashimoto.andcode.feature.settings

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.yugahashimoto.andcode.R

Expand All @@ -24,12 +33,20 @@ fun CustomProviderDialog(
onNameChange: (String) -> Unit,
onBaseUrlChange: (String) -> Unit,
onModelsChange: (String) -> Unit,
onApiKeyChange: (String) -> Unit,
onFetchModels: () -> Unit,
onSubmit: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (!state.isSubmitting) onDismiss() },
title = { Text(stringResource(R.string.provider_add_custom_title)) },
title = {
Text(
stringResource(
if (state.editing) R.string.provider_edit_custom_title else R.string.provider_add_custom_title,
),
)
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
Expand All @@ -46,7 +63,7 @@ fun CustomProviderDialog(
label = { Text(stringResource(R.string.provider_custom_id_label)) },
placeholder = { Text(stringResource(R.string.provider_custom_id_hint)) },
singleLine = true,
enabled = !state.isSubmitting,
enabled = !state.isBusy && !state.editing,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
Expand All @@ -55,7 +72,7 @@ fun CustomProviderDialog(
label = { Text(stringResource(R.string.provider_custom_name_label)) },
placeholder = { Text(stringResource(R.string.provider_custom_name_hint)) },
singleLine = true,
enabled = !state.isSubmitting,
enabled = !state.isBusy,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
Expand All @@ -64,16 +81,63 @@ fun CustomProviderDialog(
label = { Text(stringResource(R.string.provider_custom_base_url_label)) },
placeholder = { Text(stringResource(R.string.provider_custom_base_url_hint)) },
singleLine = true,
enabled = !state.isSubmitting,
enabled = !state.isBusy,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.apiKey,
onValueChange = onApiKeyChange,
label = { Text(stringResource(R.string.api_key_hint)) },
supportingText = {
Text(
stringResource(
if (state.hasStoredApiKey) {
R.string.provider_custom_api_key_stored_help
} else {
R.string.provider_custom_api_key_help
},
),
)
},
singleLine = true,
enabled = !state.isBusy,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onFetchModels, enabled = !state.isBusy) {
Text(stringResource(R.string.provider_custom_fetch_models))
}
if (state.isFetchingModels) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
}
}
state.fetchedModelCount?.let { count ->
Text(
pluralStringResource(R.plurals.provider_custom_fetch_success, count, count),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
state.fetchError?.let { error ->
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
OutlinedTextField(
value = state.models,
onValueChange = onModelsChange,
label = { Text(stringResource(R.string.provider_custom_models_label)) },
placeholder = { Text(stringResource(R.string.provider_custom_models_hint)) },
singleLine = true,
enabled = !state.isSubmitting,
maxLines = 6,
enabled = !state.isBusy,
modifier = Modifier.fillMaxWidth(),
)
state.error?.let { error ->
Expand All @@ -86,8 +150,8 @@ fun CustomProviderDialog(
}
},
confirmButton = {
Button(onClick = onSubmit, enabled = !state.isSubmitting) {
Text(stringResource(R.string.continue_label))
Button(onClick = onSubmit, enabled = !state.isBusy) {
Text(stringResource(if (state.editing) R.string.save else R.string.continue_label))
}
},
dismissButton = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.yugahashimoto.andcode.core.api.McpServer
import com.yugahashimoto.andcode.runtime.LocalAgent
import com.yugahashimoto.andcode.runtime.OpenCodeBackend
import com.yugahashimoto.andcode.runtime.RuntimeRegistry
import com.yugahashimoto.andcode.runtime.RuntimeTarget
import com.yugahashimoto.andcode.runtime.local.ClaudeCodeTarget
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -77,7 +78,16 @@ class McpViewModel(
_state.update { it.copy(isLoading = true, error = null) }
runCatching { backend.mcpServers() }
.onSuccess { servers ->
_state.update { it.copy(servers = servers, isLoading = false) }
// Read after the list call: that call is what lets a remote target detect its
// protocol, and an OpenCode V2 server has no MCP OAuth endpoints to offer.
val oauthOffered = (backend as? RuntimeTarget)?.capabilities?.mcpOAuth != false
_state.update {
it.copy(
servers = servers,
isLoading = false,
supportsOAuth = agent == LocalAgent.OPEN_CODE && oauthOffered,
)
}
}
.onFailure { e ->
_state.update { it.copy(error = e.message, isLoading = false) }
Expand Down
Loading
Loading