diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt index 7454b382..551cfaf7 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt @@ -28,6 +28,7 @@ import com.codedeck.plus.ui.screens.MachineSettingsScreen import com.codedeck.plus.ui.screens.PairingScreen import com.codedeck.plus.ui.screens.McpScreen import com.codedeck.plus.ui.screens.PluginsScreen +import com.codedeck.plus.ui.screens.ProvidersScreen import com.codedeck.plus.ui.screens.SettingsScreen import com.codedeck.plus.ui.session.SessionScreen import com.codedeck.plus.ui.theme.Tokens @@ -50,6 +51,7 @@ private sealed interface Screen { /** One agent's plugins on a machine, opened from the machine's page. */ data class Plugins(val machine: String, val agent: String) : Screen data class Mcp(val machine: String, val agent: String) : Screen + data class Providers(val machine: String, val agent: String) : Screen } /** [Screen] as a flat string list: what [ScreenSaver] stores, and (joined) @@ -64,6 +66,7 @@ private fun Screen.parts(): List = when (this) { is Screen.Machine -> listOf("machine", machine) is Screen.Plugins -> listOf("plugins", machine, agent) is Screen.Mcp -> listOf("mcp", machine, agent) + is Screen.Providers -> listOf("providers", machine, agent) } private fun Screen.stateKey(): String = parts().joinToString("/") @@ -72,7 +75,7 @@ private fun Screen.stateKey(): String = parts().joinToString("/") * shallower one. */ private fun Screen.depth(): Int = when (this) { Screen.Sessions -> 0 - Screen.Logs, is Screen.Plugins, is Screen.Mcp -> 2 + Screen.Logs, is Screen.Plugins, is Screen.Mcp, is Screen.Providers -> 2 else -> 1 } @@ -90,6 +93,7 @@ private val ScreenSaver = listSaver( "machine" -> Screen.Machine(saved[1]) "plugins" -> Screen.Plugins(saved[1], saved[2]) "mcp" -> Screen.Mcp(saved[1], saved[2]) + "providers" -> Screen.Providers(saved[1], saved[2]) else -> Screen.Sessions } }, @@ -242,6 +246,7 @@ fun Shell( Screen.Logs -> Screen.Settings is Screen.Plugins -> Screen.Machine(current.machine) is Screen.Mcp -> Screen.Machine(current.machine) + is Screen.Providers -> Screen.Machine(current.machine) else -> Screen.Sessions }, ) @@ -262,11 +267,13 @@ fun Shell( core, current.machine, onBack = { go(Screen.Sessions) }, + onOpenProviders = { agent -> go(Screen.Providers(current.machine, agent)) }, onOpenPlugins = { agent -> go(Screen.Plugins(current.machine, agent)) }, onOpenMcp = { agent -> go(Screen.Mcp(current.machine, agent)) }, ) is Screen.Plugins -> PluginsScreen(core, current.machine, current.agent, onBack = { go(Screen.Machine(current.machine)) }) is Screen.Mcp -> McpScreen(core, current.machine, current.agent, onBack = { go(Screen.Machine(current.machine)) }) + is Screen.Providers -> ProvidersScreen(core, current.machine, current.agent, onBack = { go(Screen.Machine(current.machine)) }) Screen.Logs -> LogsScreen(onBack = { go(Screen.Settings) }) Screen.Pairing -> PairingScreen(core, onClose = { go(Screen.Sessions) }) is Screen.NewSession -> NewSessionScreen( diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt index 71aa6058..f858b549 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/components/SelectField.kt @@ -97,8 +97,10 @@ fun SelectField( val current = options.firstOrNull { it.value == selected } val currentLabel = current?.label ?: if (selected.isEmpty()) placeholder else selected - // Groups are named only when there is more than one to tell apart. - val grouped = options.mapNotNull { it.group }.distinct().size > 1 + // A list that says who offers its entries always shows it, even when + // one provider offers them all: which provider a model runs on is + // worth seeing whether or not there is another to choose. + val grouped = options.any { it.group != null } Box { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt deleted file mode 100644 index 39a50d7a..00000000 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineProviders.kt +++ /dev/null @@ -1,361 +0,0 @@ -package com.codedeck.plus.ui.screens - -import androidx.compose.foundation.background -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.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.input.PasswordVisualTransformation -import com.codedeck.plus.ui.components.Chip -import com.codedeck.plus.ui.components.Field -import com.codedeck.plus.ui.components.IconAction -import com.codedeck.plus.ui.components.PickerOption -import com.codedeck.plus.ui.components.PrimaryButton -import com.codedeck.plus.ui.components.QuietButton -import com.codedeck.plus.ui.components.SecondaryButton -import com.codedeck.plus.ui.components.SelectField -import com.codedeck.plus.ui.theme.Tokens -import uniffi.client_ffi.UniffiIntent -import uniffi.client_ffi.UniffiMachineSummary -import uniffi.client_ffi.UniffiProviderModelWrite -import uniffi.client_ffi.UniffiProviderProfileAck -import uniffi.client_ffi.UniffiProviderProfileInfo -import uniffi.client_ffi.UniffiProviderProfileWrite -import uniffi.client_ffi.UniffiTristate -import uniffi.client_ffi.isValidProviderBaseUrl -import uniffi.client_ffi.providerBaseUrlError - -/** Draft row of the models editor ('' label = omit on the wire). */ -private data class ModelRow(val id: String, val label: String) - -private val EMPTY_ROW = ModelRow("", "") - -/** - * Human-legible profile id from the label (house slug style), suffixed on - * collision so re-adding "Kimi K3" never silently overwrites a profile. Port - * of `MachineProviders.tsx`'s `profileIdFromLabel`. - */ -private fun profileIdFromLabel(label: String, taken: Set): String { - val base = label.lowercase().trim().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "profile" } - if (base !in taken) return base - var n = 2 - while ("$base-$n" in taken) n++ - return "$base-$n" -} - -/** Add-form prefills for the two providers the feature was built around. */ -private data class Preset(val name: String, val baseUrl: String, val models: List, val defaultModel: String) - -private val PRESETS = listOf( - Preset("Kimi K3", "https://api.moonshot.ai/anthropic", listOf(ModelRow("kimi-k3", "Kimi K3")), "kimi-k3"), - Preset("OpenRouter", "https://openrouter.ai/api", listOf(EMPTY_ROW), ""), -) - -/** - * Machine AI provider profiles (CDX-062) — port of `apps/mobile/src/ui/ - * screens/MachineProviders.tsx`. The caller gates this on - * `machine.capabilities.contains(CAP_CUSTOM_PROVIDERS)` before invoking it - * (mirrors the TSX's cap gate) — Compose has no rules-of-hooks constraint - * forcing the gate to live inside this composable. - * - * One disclosed narrowing from the TSX: `UniffiMachineSummary.providerProfiles` - * is a plain (non-optional) list on this FFI surface, so there is no "not yet - * answered" vs. "answered empty" distinction to render a "Loading provider - * profiles…" state for — the same narrowing `NewSessionScreen.kt` already - * accepted for this same field. - */ -@Composable -fun MachineProviders(machine: UniffiMachineSummary, status: UniffiProviderProfileAck?, dispatch: (UniffiIntent) -> Unit) { - val profiles = machine.providerProfiles - - var formOpen by remember(machine.pubkeyHex) { mutableStateOf(false) } - /** Profile id being edited; null = the add form. */ - var editingId by remember(machine.pubkeyHex) { mutableStateOf(null) } - var label by remember(machine.pubkeyHex) { mutableStateOf("") } - var baseUrl by remember(machine.pubkeyHex) { mutableStateOf("") } - var token by remember(machine.pubkeyHex) { mutableStateOf("") } - var clearToken by remember(machine.pubkeyHex) { mutableStateOf(false) } - var models by remember(machine.pubkeyHex) { mutableStateOf(listOf(EMPTY_ROW)) } - var defaultModel by remember(machine.pubkeyHex) { mutableStateOf("") } - /** Which profile's Delete awaits its confirm step. */ - var confirmDelete by remember(machine.pubkeyHex) { mutableStateOf(null) } - var saving by remember(machine.pubkeyHex) { mutableStateOf(false) } - - LaunchedEffect(machine.pubkeyHex) { - dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) - } - - /** A save of the open form is waiting for the bridge's ack. */ - var awaitingSave by remember(machine.pubkeyHex) { mutableStateOf(false) } - val editingProfile = editingId?.let { id -> profiles.find { it.id == id } } - val validModels = models.filter { it.id.trim().isNotEmpty() } - val trimmedBaseUrl = baseUrl.trim() - // Only once there is something to check: an empty field is not an error yet. - val baseUrlValid = trimmedBaseUrl.isNotEmpty() && isValidProviderBaseUrl(trimmedBaseUrl) - val baseUrlError = trimmedBaseUrl.isNotEmpty() && !baseUrlValid - val canSave = label.trim().isNotEmpty() && baseUrlValid && validModels.isNotEmpty() - - fun resetForm() { - editingId = null - label = "" - baseUrl = "" - token = "" - clearToken = false - models = listOf(EMPTY_ROW) - defaultModel = "" - } - - LaunchedEffect(status) { - if (status == null) return@LaunchedEffect - saving = false - if (awaitingSave && status.state != "saving") { - awaitingSave = false - if (status.state == "saved") { - formOpen = false - resetForm() - } - } - } - - fun openAdd() { - resetForm() - formOpen = true - } - - fun openEdit(p: UniffiProviderProfileInfo) { - editingId = p.id - label = p.label - baseUrl = p.baseUrl - token = "" - clearToken = false - models = p.models.map { ModelRow(it.id, it.label ?: "") } - defaultModel = p.defaultModel ?: "" - formOpen = true - } - - fun applyPreset(preset: Preset) { - label = preset.name - baseUrl = preset.baseUrl - models = preset.models - defaultModel = preset.defaultModel - } - - fun save() { - val profileId = editingId ?: profileIdFromLabel(label, profiles.map { it.id }.toSet()) - val wireModels = validModels.map { m -> - UniffiProviderModelWrite(id = m.id.trim(), label = m.label.trim().ifEmpty { null }) - } - val authToken = when { - clearToken -> UniffiTristate.Clear - token.trim().isNotEmpty() -> UniffiTristate.Set(token.trim()) - else -> UniffiTristate.Keep - } - val resolvedDefault = if (defaultModel.isNotEmpty() && wireModels.any { it.id == defaultModel }) defaultModel else null - saving = true - dispatch( - UniffiIntent.SetProviderProfile( - machine = machine.pubkeyHex, - profileId = profileId, - profile = UniffiProviderProfileWrite( - label = label.trim(), - baseUrl = baseUrl.trim(), - authToken = authToken, - models = wireModels, - defaultModel = resolvedDefault, - ), - ), - ) - // The secret leaves component state at once; the rest of the form - // stays open until the bridge confirms, so a failed save can be - // corrected and retried instead of retyped (see LaunchedEffect(status)). - token = "" - clearToken = false - awaitingSave = true - } - - fun deleteProfile(profileId: String) { - confirmDelete = null - saving = true - dispatch(UniffiIntent.SetProviderProfile(machine = machine.pubkeyHex, profileId = profileId, profile = null)) - if (editingId == profileId) { - formOpen = false - resetForm() - } - } - - Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space3)) { - Text( - "Anthropic-compatible providers (Kimi, OpenRouter, …) kept on the bridge. A new session can " + - "run on one. Their tokens stay on the bridge; this phone never stores them.", - color = Tokens.TextMuted, - fontSize = Tokens.TextSm, - ) - - profiles.forEach { p -> - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Tokens.RadiusLg)) - .background(Tokens.SurfaceInput) - .padding(Tokens.Space3), - verticalArrangement = Arrangement.spacedBy(Tokens.Space1), - ) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Text(p.label, color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) - Chip( - if (p.hasToken) "token set" else "no token", - color = if (p.hasToken) Tokens.Success else Tokens.TextDim, - border = if (p.hasToken) Tokens.Success.copy(alpha = 0.4f) else Tokens.Border, - ) - } - Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) - Text( - "${p.models.size} ${if (p.models.size == 1) "model" else "models"}" + - (p.defaultModel?.let { ", default $it" } ?: ""), - color = Tokens.TextMuted, - fontSize = Tokens.TextSm, - ) - if (confirmDelete == p.id) { - Text( - "Delete ${p.label} from the bridge? Sessions bound to it will fail on their " + - "next restart instead of silently falling back to Anthropic.", - color = Tokens.Danger, - fontSize = Tokens.TextSm, - ) - Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - SecondaryButton("Delete provider", onClick = { deleteProfile(p.id) }, danger = true) - QuietButton("Cancel", onClick = { confirmDelete = null }) - } - } else { - Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { - QuietButton("Edit", onClick = { openEdit(p) }) - QuietButton("Delete", onClick = { confirmDelete = p.id }, danger = true) - } - } - } - } - - if (!formOpen) { - SecondaryButton("Add provider", onClick = ::openAdd) - } else { - Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - if (editingId == null) { - Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { - PRESETS.forEach { preset -> QuietButton(preset.name, onClick = { applyPreset(preset) }) } - } - } - Field(value = label, onValueChange = { label = it }, label = "Name", placeholder = "Kimi K3") - Field( - value = baseUrl, - onValueChange = { baseUrl = it }, - label = "Base URL", - placeholder = "https://api.moonshot.ai/anthropic", - mono = true, - isError = baseUrlError, - supporting = if (baseUrlError) providerBaseUrlError() else null, - ) - Field( - value = token, - onValueChange = { token = it }, - label = "API token", - placeholder = if (editingProfile?.hasToken == true) "unchanged" else "sk-…", - visualTransformation = PasswordVisualTransformation(), - ) - if (editingProfile?.hasToken == true) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Checkbox( - checked = clearToken, - onCheckedChange = { - clearToken = it - if (it) token = "" - }, - ) - Text("Delete the stored token when saving", color = Tokens.TextMuted, fontSize = Tokens.TextSm) - } - } - models.forEachIndexed { i, row -> - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), - ) { - Field( - value = row.id, - onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(id = v) else m } }, - placeholder = "model id", - mono = true, - modifier = Modifier.weight(1f), - ) - Field( - value = row.label, - onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(label = v) else m } }, - placeholder = "label", - modifier = Modifier.weight(1f), - ) - IconAction( - Icons.Outlined.Close, - "Remove model", - onClick = { models = models.filterIndexed { j, _ -> j != i } }, - tint = if (models.size > 1) Tokens.TextMuted else Tokens.TextDim, - // A provider keeps at least one model. - enabled = models.size > 1, - ) - } - } - QuietButton("Add model", onClick = { models = models + EMPTY_ROW }) - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Text("Default model", color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) - SelectField( - options = buildList { - add(PickerOption("", "First model")) - validModels.forEach { m -> - val id = m.id.trim() - add(PickerOption(id, m.label.trim().ifEmpty { id })) - } - }, - selected = defaultModel, - onSelect = { defaultModel = it }, - ) - } - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - PrimaryButton("Save on the bridge", onClick = ::save, enabled = canSave, modifier = Modifier.weight(1f)) - QuietButton("Cancel", onClick = { - formOpen = false - resetForm() - }) - } - } - } - - if (status != null) { - val text = when (status.state) { - "saving" -> "Saving on the bridge…" - "saved" -> "Saved" + when (status.tokenValid) { - true -> ", token valid" - false -> ", token rejected" - null -> "" - } - "failed" -> "Saving failed: ${status.error ?: "unknown error"}" - else -> "" - } - Text(text, color = if (status.state == "failed") Tokens.Danger else Tokens.TextMuted, fontSize = Tokens.TextSm) - } - } -} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt index 92fec7ef..d5ee7e5f 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt @@ -111,9 +111,9 @@ internal fun shortKey(key: String): String = if (key.length <= 20) key else "${k /** * One machine's page: who it is and whether it is up, what its new sessions * start with, the relays it is reached over, its direct link, the - * credentials and AI providers kept on it, its agents' plugins (each on a - * page of its own, opened through [onOpenPlugins]; MCP servers through - * [onOpenMcp]), and forgetting it. Pure — the + * credentials kept on it, its agents' AI providers, plugins and MCP + * servers (each agent's on a page of its own, opened through + * [onOpenProviders], [onOpenPlugins] and [onOpenMcp]), and forgetting it. Pure — the * caller supplies the machine and a dispatcher — so it renders the same in * a snapshot. */ @@ -126,14 +126,15 @@ fun MachineSettingsContent( now: Long, dispatch: (UniffiIntent) -> Unit, onBack: () -> Unit, + onOpenProviders: (agent: String) -> Unit, onOpenPlugins: (agent: String) -> Unit, onOpenMcp: (agent: String) -> Unit, ) { - // The model pickers need each agent's list, and the plugin rows their - // counts: ask for them on opening. + // The model pickers need each agent's list, and the provider and plugin + // rows their counts: ask for them on opening. LaunchedEffect(machine.pubkeyHex) { machine.agents.filter { it.supportsModels }.forEach { dispatch(UniffiIntent.RequestModels(machine.pubkeyHex, it.id)) } - if (machine.agents.any { it.supportsProviders }) dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) + if (providerAgents(machine).isNotEmpty()) dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) machine.agents.filter { it.supportsPlugins }.forEach { dispatch(UniffiIntent.RequestPlugins(machine.pubkeyHex, it.id, available = false)) } @@ -158,9 +159,14 @@ fun MachineSettingsContent( GroupBody { MachineCredentials(machine, credentialsStatus, dispatch) } } } - if (machine.agents.any { it.supportsProviders }) { - Group(title = "AI providers") { - GroupBody { MachineProviders(machine, providerProfileStatus, dispatch) } + val providerAgents = providerAgents(machine) + if (providerAgents.isNotEmpty()) { + Group(title = "AI providers", footer = "API endpoints an agent can use: a service, or a gateway of your own. Each is kept for one agent.") { + providerAgents.forEachIndexed { i, agent -> + if (i > 0) Divider() + val count = machine.providerProfiles.count { it.agent == agent.id } + NavRow(agent.displayName, onClick = { onOpenProviders(agent.id) }, value = if (count == 1) "1 provider" else "$count providers") + } } } val pluginAgents = machine.agents.filter { it.supportsPlugins } @@ -411,6 +417,7 @@ fun MachineSettingsScreen( core: CoreHost, pubkey: String, onBack: () -> Unit, + onOpenProviders: (agent: String) -> Unit, onOpenPlugins: (agent: String) -> Unit, onOpenMcp: (agent: String) -> Unit, ) { @@ -426,6 +433,7 @@ fun MachineSettingsScreen( now = System.currentTimeMillis(), dispatch = { intent -> scope.launch { core.dispatch(intent) } }, onBack = onBack, + onOpenProviders = onOpenProviders, onOpenPlugins = onOpenPlugins, onOpenMcp = onOpenMcp, ) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt index 03aa8276..b2bdd07a 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt @@ -161,9 +161,10 @@ internal fun NewSessionBody( val newFolderPath = newFolder.trim() val canCreate = agent != null && (folderChoice != NEW_FOLDER || newFolderPath.isNotEmpty()) - // Custom provider profiles only for an agent that can use them. + // The agent's own provider profiles, when a session can be bound to one + // (an agent whose profiles add models offers them in the model list). val providerProfiles: List = - if (agent?.supportsProviders == true) machine.providerProfiles else emptyList() + if (agent?.supportsProviders == true) machine.providerProfiles.filter { it.agent == agentId } else emptyList() val activeProfile = if (providerId != "") providerProfiles.find { it.id == providerId } else null // Each agent has its own model list — the Model picker never mixes them. val agentModels = machine.models.firstOrNull { it.agent == agentId } @@ -199,14 +200,12 @@ internal fun NewSessionBody( val name = choices.firstOrNull { it.first == id }?.second ?: id return "Default $kind ($name)" } - // The same model can come from more than one provider; then the - // default names its provider too. - val manyProviders = modelOptions.mapNotNull { it.provider }.distinct().size > 1 + // The default names its provider too, as every entry in the list does. val defaultModelLabel = defaultLabel( "model", // A profile with no default of its own runs its first model. if (activeProfile != null) activeProfile.defaultModel ?: activeProfile.models.firstOrNull()?.id else agentModels?.defaultModel, - modelOptions.map { m -> m.id to (m.label ?: m.id) + (m.provider?.takeIf { manyProviders }?.let { " from $it" } ?: "") }, + modelOptions.map { m -> m.id to (m.label ?: m.id) + (m.provider?.let { " from $it" } ?: "") }, ) fun create() { diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt new file mode 100644 index 00000000..1e0b2e1e --- /dev/null +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt @@ -0,0 +1,506 @@ +package com.codedeck.plus.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import com.codedeck.plus.core.CoreHost +import com.codedeck.plus.ui.components.Chip +import com.codedeck.plus.ui.components.ErrorNote +import com.codedeck.plus.ui.components.Field +import com.codedeck.plus.ui.components.Group +import com.codedeck.plus.ui.components.GroupBody +import com.codedeck.plus.ui.components.IconAction +import com.codedeck.plus.ui.components.Page +import com.codedeck.plus.ui.components.PickerOption +import com.codedeck.plus.ui.components.PrimaryButton +import com.codedeck.plus.ui.components.QuietButton +import com.codedeck.plus.ui.components.SecondaryButton +import com.codedeck.plus.ui.components.SelectField +import com.codedeck.plus.ui.components.ValueRow +import com.codedeck.plus.ui.components.machineLabel +import com.codedeck.plus.ui.theme.Tokens +import kotlinx.coroutines.launch +import uniffi.client_ffi.UniffiAgent +import uniffi.client_ffi.UniffiIntent +import uniffi.client_ffi.UniffiMachineSummary +import uniffi.client_ffi.UniffiProviderModelWrite +import uniffi.client_ffi.UniffiProviderProfileAck +import uniffi.client_ffi.UniffiProviderProfileInfo +import uniffi.client_ffi.UniffiProviderProfileWrite +import uniffi.client_ffi.UniffiTristate +import uniffi.client_ffi.isValidProviderBaseUrl +import uniffi.client_ffi.providerBaseUrlError + +/** Draft row of the models editor ('' label = omit on the wire). */ +private data class ModelRow(val id: String, val label: String) + +private val EMPTY_ROW = ModelRow("", "") + +/** + * Human-legible profile id from the label (house slug style), suffixed on + * collision so re-adding "Kimi K3" never silently overwrites a profile. + */ +private fun profileIdFromLabel(label: String, taken: Set): String { + val base = label.lowercase().trim().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "profile" } + if (base !in taken) return base + var n = 2 + while ("$base-$n" in taken) n++ + return "$base-$n" +} + +/** Add-form prefills for two common providers. [fromProvider]: the + * provider lists its own models, so none are typed. */ +private data class Preset( + val name: String, + val baseUrl: String, + val models: List, + val defaultModel: String, + val fromProvider: Boolean, +) + +private val PRESETS = listOf( + Preset("Kimi K3", "https://api.moonshot.ai/anthropic", listOf(ModelRow("kimi-k3", "Kimi K3")), "kimi-k3", fromProvider = false), + Preset("OpenRouter", "https://openrouter.ai/api", listOf(EMPTY_ROW), "", fromProvider = true), +) + +/** The agents a provider can be for. */ +internal fun providerAgents(machine: UniffiMachineSummary): List = + machine.agents.filter { it.supportsProviders || it.supportsProviderModels } + +/** What a provider does for [agent]. */ +internal fun providerUse(agent: UniffiAgent): String = + if (agent.supportsProviderModels) { + "Its models join ${agent.displayName}'s model list, beside the providers ${agent.displayName} already has." + } else { + "A new ${agent.displayName} session can run on it instead of ${agent.displayName}'s own account." + } + +/** What the open editor is: a new provider, or the profile with this id. */ +sealed interface ProviderEditor { + data object New : ProviderEditor + data class Existing(val id: String) : ProviderEditor +} + +/** + * One agent's AI providers on a machine — its provider profiles, kept per + * agent like its plugins and MCP servers: an endpoint that speaks one + * agent's API need not speak another's. A list page with an Add button; + * adding or editing opens the editor in its place, and Back returns. + * + * A profile saved before profiles had an agent is offered on every agent's + * page, to be taken over with one tap. + */ +@Composable +fun ProvidersContent( + machine: UniffiMachineSummary, + agentId: String, + status: UniffiProviderProfileAck?, + dispatch: (UniffiIntent) -> Unit, + onBack: () -> Unit, + /** Opens straight on the editor (for a snapshot). */ + startEditing: ProviderEditor? = null, + /** The core's base URL rule — a native call, so a snapshot, which cannot + * load the core, passes its own. */ + validBaseUrl: (String) -> Boolean = ::isValidProviderBaseUrl, +) { + val agent = machine.agents.firstOrNull { it.id == agentId } + val agentName = agent?.displayName ?: agentId + val known = providerAgents(machine).map { it.id }.toSet() + val own = machine.providerProfiles.filter { it.agent == agentId } + // Profiles from before profiles named their agent. + val unassigned = machine.providerProfiles.filter { it.agent !in known } + + var editor by remember(machine.pubkeyHex, agentId) { mutableStateOf(startEditing) } + var confirmDelete by remember(machine.pubkeyHex, agentId) { mutableStateOf(null) } + /** A change made from the list (refresh, take over, delete) awaits its ack. */ + var busy by remember(machine.pubkeyHex, agentId) { mutableStateOf(false) } + + LaunchedEffect(machine.pubkeyHex) { dispatch(UniffiIntent.RequestProviderProfiles(machine.pubkeyHex)) } + LaunchedEffect(status) { if (status != null && status.state != "saving") busy = false } + + fun write(profileId: String, profile: UniffiProviderProfileWrite?) { + busy = true + dispatch(UniffiIntent.SetProviderProfile(machine = machine.pubkeyHex, profileId = profileId, profile = profile)) + } + + /** The same profile for [forAgent], its token kept and its models read + * again when they are the provider's. */ + fun rewrite(p: UniffiProviderProfileInfo, forAgent: String) = UniffiProviderProfileWrite( + agent = forAgent, + label = p.label, + baseUrl = p.baseUrl, + authToken = UniffiTristate.Keep, + models = if (p.modelsFromProvider) emptyList() else p.models.map { UniffiProviderModelWrite(it.id, it.label) }, + modelsFromProvider = p.modelsFromProvider, + defaultModel = p.defaultModel, + ) + + val open = editor + if (open != null) { + ProviderEditorPage( + machine = machine, + agentId = agentId, + agentName = agentName, + editing = (open as? ProviderEditor.Existing)?.let { e -> own.firstOrNull { it.id == e.id } }, + status = status, + dispatch = dispatch, + validBaseUrl = validBaseUrl, + onDone = { editor = null }, + ) + return + } + + Page( + title = "$agentName providers", + subtitle = machineLabel(machine.name), + onBack = onBack, + bottomBar = { PrimaryButton("Add a provider", onClick = { editor = ProviderEditor.New }, modifier = Modifier.fillMaxWidth()) }, + ) { + if (own.isEmpty()) { + Group { + GroupBody { + Text("No providers yet", color = Tokens.Text, fontSize = Tokens.TextLg) + Text( + "A provider is an API endpoint $agentName can use: a service such as OpenRouter or Kimi, " + + "or a gateway of your own, on this machine or your network. " + agent?.let(::providerUse).orEmpty(), + color = Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) + } + } + } else { + Group(footer = agent?.let { providerUse(it) + " Tokens stay on the machine; this phone never stores them." }) { + own.forEachIndexed { i, p -> + if (i > 0) Divider() + GroupBody { + ProfileSummary(p) + if (confirmDelete == p.id) { + Text( + if (agent?.supportsProviderModels == true) { + "Delete ${p.label}? Its models leave $agentName's list." + } else { + "Delete ${p.label}? Sessions on it fail at their next restart instead of " + + "falling back to $agentName's own account." + }, + color = Tokens.Danger, + fontSize = Tokens.TextSm, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + SecondaryButton("Delete provider", onClick = { + confirmDelete = null + write(p.id, null) + }, danger = true) + QuietButton("Cancel", onClick = { confirmDelete = null }) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + QuietButton("Edit", onClick = { editor = ProviderEditor.Existing(p.id) }) + if (p.modelsFromProvider && p.hasToken) { + QuietButton("Refresh models", onClick = { write(p.id, rewrite(p, agentId)) }, enabled = !busy) + } + QuietButton("Delete", onClick = { confirmDelete = p.id }, danger = true) + } + } + } + } + } + } + + if (unassigned.isNotEmpty()) { + Group( + title = "Saved without an agent", + footer = "Saved before each provider was kept for one agent, so no agent uses them. " + + "Use one for $agentName if it speaks the API $agentName uses.", + ) { + unassigned.forEachIndexed { i, p -> + if (i > 0) Divider() + GroupBody { + ProfileSummary(p) + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + QuietButton("Use for $agentName", onClick = { write(p.id, rewrite(p, agentId)) }, enabled = !busy && p.hasToken) + QuietButton("Delete", onClick = { write(p.id, null) }, danger = true, enabled = !busy) + } + } + } + } + } + + ProfileStatus(status) + } +} + +@Composable +private fun ProfileSummary(p: UniffiProviderProfileInfo) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Text(p.label, color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) + Chip( + if (p.hasToken) "token set" else "no token", + color = if (p.hasToken) Tokens.Success else Tokens.TextDim, + border = if (p.hasToken) Tokens.Success.copy(alpha = 0.4f) else Tokens.Border, + ) + } + Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) + Text( + "${p.models.size} ${if (p.models.size == 1) "model" else "models"}" + + (if (p.modelsFromProvider) " from the provider" else "") + + (p.defaultModel?.let { ", default $it" } ?: ""), + color = Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) +} + +/** The last save's outcome, in words. */ +@Composable +private fun ProfileStatus(status: UniffiProviderProfileAck?) { + if (status == null) return + when (status.state) { + "saving" -> Text("Saving on the machine…", color = Tokens.TextMuted, fontSize = Tokens.TextSm) + "saved" -> Text( + "Saved" + when (status.tokenValid) { + true -> ", token valid" + false -> ", but the provider rejected the token" + null -> "" + }, + color = if (status.tokenValid == false) Tokens.Warn else Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) + "failed" -> ErrorNote("Saving failed: ${status.error ?: "unknown error"}") + } +} + +/** The editor of one provider: a new one when [editing] is null. Its token + * leaves the form once sent; the rest stays until the machine confirms, so + * a failed save can be corrected instead of retyped. */ +@Composable +private fun ProviderEditorPage( + machine: UniffiMachineSummary, + agentId: String, + agentName: String, + editing: UniffiProviderProfileInfo?, + status: UniffiProviderProfileAck?, + dispatch: (UniffiIntent) -> Unit, + validBaseUrl: (String) -> Boolean, + onDone: () -> Unit, +) { + val key = editing?.id ?: "" + var label by remember(key) { mutableStateOf(editing?.label.orEmpty()) } + var baseUrl by remember(key) { mutableStateOf(editing?.baseUrl.orEmpty()) } + var token by remember(key) { mutableStateOf("") } + var clearToken by remember(key) { mutableStateOf(false) } + /** The machine reads the models from the provider instead of [models]. */ + var fromProvider by remember(key) { mutableStateOf(editing?.modelsFromProvider ?: true) } + var models by remember(key) { + mutableStateOf(editing?.takeIf { !it.modelsFromProvider }?.models?.map { ModelRow(it.id, it.label.orEmpty()) } ?: listOf(EMPTY_ROW)) + } + var defaultModel by remember(key) { mutableStateOf(editing?.defaultModel.orEmpty()) } + var awaitingSave by remember(key) { mutableStateOf(false) } + + LaunchedEffect(status) { + if (awaitingSave && status != null && status.state != "saving") { + awaitingSave = false + if (status.state == "saved") onDone() + } + } + + val validModels = models.filter { it.id.trim().isNotEmpty() } + val trimmedBaseUrl = baseUrl.trim() + // Only once there is something to check: an empty field is not an error yet. + val baseUrlValid = trimmedBaseUrl.isNotEmpty() && validBaseUrl(trimmedBaseUrl) + val baseUrlError = trimmedBaseUrl.isNotEmpty() && !baseUrlValid + // Reading the provider's list takes its token: a new one, or the stored one. + val hasTokenToUse = token.trim().isNotEmpty() || (editing?.hasToken == true && !clearToken) + val canSave = !awaitingSave && label.trim().isNotEmpty() && baseUrlValid && + (if (fromProvider) hasTokenToUse else validModels.isNotEmpty()) + // What the default-model picker offers: the typed list, or what the + // provider listed at the last save. + val defaultChoices: List> = + if (fromProvider) { + editing?.takeIf { it.modelsFromProvider }?.models.orEmpty().map { it.id to (it.label ?: it.id) } + } else { + validModels.map { m -> m.id.trim() to m.label.trim().ifEmpty { m.id.trim() } } + } + + fun save() { + val profileId = editing?.id ?: profileIdFromLabel(label, machine.providerProfiles.map { it.id }.toSet()) + val wireModels = validModels.map { m -> UniffiProviderModelWrite(id = m.id.trim(), label = m.label.trim().ifEmpty { null }) } + val authToken = when { + clearToken -> UniffiTristate.Clear + token.trim().isNotEmpty() -> UniffiTristate.Set(token.trim()) + else -> UniffiTristate.Keep + } + // A provider-listed default the provider no longer lists is dropped + // by the machine, which reads the list anew. + val resolvedDefault = when { + defaultModel.isEmpty() -> null + fromProvider || wireModels.any { it.id == defaultModel } -> defaultModel + else -> null + } + dispatch( + UniffiIntent.SetProviderProfile( + machine = machine.pubkeyHex, + profileId = profileId, + profile = UniffiProviderProfileWrite( + agent = agentId, + label = label.trim(), + baseUrl = trimmedBaseUrl, + authToken = authToken, + models = if (fromProvider) emptyList() else wireModels, + modelsFromProvider = fromProvider, + defaultModel = resolvedDefault, + ), + ), + ) + token = "" + clearToken = false + awaitingSave = true + } + + Page( + title = editing?.let { "Edit ${it.label}" } ?: "Add a provider", + subtitle = "$agentName · ${machineLabel(machine.name)}", + onBack = onDone, + bottomBar = { + PrimaryButton(if (awaitingSave) "Saving…" else "Save on the machine", onClick = ::save, enabled = canSave, modifier = Modifier.fillMaxWidth()) + }, + ) { + if (editing == null) { + Group(title = "Start from") { + GroupBody { + Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + PRESETS.forEach { preset -> + QuietButton(preset.name, onClick = { + label = preset.name + baseUrl = preset.baseUrl + models = preset.models + defaultModel = preset.defaultModel + fromProvider = preset.fromProvider + }) + } + } + } + } + } + Group( + title = "Endpoint", + footer = "It must speak the API $agentName uses. http:// works only for this machine or an address on your own network.", + ) { + GroupBody { + Field(value = label, onValueChange = { label = it }, label = "Name", placeholder = "My gateway") + Field( + value = baseUrl, + onValueChange = { baseUrl = it }, + label = "Base URL", + placeholder = "https://api.example.com", + mono = true, + isError = baseUrlError, + supporting = if (baseUrlError) providerBaseUrlError() else null, + ) + Field( + value = token, + onValueChange = { token = it }, + label = "API token", + placeholder = if (editing?.hasToken == true) "unchanged" else "sk-…", + visualTransformation = PasswordVisualTransformation(), + ) + if (editing?.hasToken == true) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Checkbox( + checked = clearToken, + onCheckedChange = { + clearToken = it + if (it) token = "" + }, + ) + Text("Delete the stored token when saving", color = Tokens.TextMuted, fontSize = Tokens.TextSm) + } + } + } + } + Group( + title = "Models", + footer = if (fromProvider) "The machine asks the provider for its model list every time you save, with the token above." else null, + ) { + GroupBody { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Checkbox(checked = fromProvider, onCheckedChange = { fromProvider = it }) + Text("Read the models from the provider", color = Tokens.Text, fontSize = Tokens.TextMd) + } + if (!fromProvider) { + models.forEachIndexed { i, row -> + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), + ) { + Field( + value = row.id, + onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(id = v) else m } }, + placeholder = "model id", + mono = true, + modifier = Modifier.weight(1f), + ) + Field( + value = row.label, + onValueChange = { v -> models = models.mapIndexed { j, m -> if (j == i) m.copy(label = v) else m } }, + placeholder = "label", + modifier = Modifier.weight(1f), + ) + IconAction( + Icons.Outlined.Close, + "Remove model", + onClick = { models = models.filterIndexed { j, _ -> j != i } }, + tint = if (models.size > 1) Tokens.TextMuted else Tokens.TextDim, + // A provider keeps at least one model. + enabled = models.size > 1, + ) + } + } + QuietButton("Add model", onClick = { models = models + EMPTY_ROW }) + } + } + Divider() + ValueRow("Default model") { + SelectField( + options = buildList { + add(PickerOption("", "First model")) + defaultChoices.forEach { (id, name) -> add(PickerOption(id, name)) } + }, + selected = defaultModel, + onSelect = { defaultModel = it }, + ) + } + } + ProfileStatus(status.takeIf { awaitingSave || it?.state == "failed" }) + } +} + +/** [ProvidersContent] for one agent of the machine [pubkey], from the core; + * closes when the machine goes away. */ +@Composable +fun ProvidersScreen(core: CoreHost, pubkey: String, agentId: String, onBack: () -> Unit) { + val ui by core.ui.collectAsState() + val scope = rememberCoroutineScope() + val machine = machineOrLeave(core, pubkey, onGone = onBack) ?: return + ProvidersContent( + machine, + agentId, + status = ui?.providerProfileStatus?.get(pubkey), + dispatch = { intent -> scope.launch { core.dispatch(intent) } }, + onBack = onBack, + ) +} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt index 1d68f46f..c322aacf 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt @@ -89,6 +89,8 @@ internal sealed interface SettingsPage { data class Plugins(val pubkey: String, val agent: String) : SettingsPage /** One agent's MCP servers on a machine, opened from the machine's page. */ data class Mcp(val pubkey: String, val agent: String) : SettingsPage + /** One agent's AI providers on a machine, opened from the machine's page. */ + data class Providers(val pubkey: String, val agent: String) : SettingsPage data object Appearance : SettingsPage data object Notifications : SettingsPage data object Connection : SettingsPage @@ -102,6 +104,7 @@ internal sealed interface SettingsPage { is Machine -> "machine:$pubkey" is Plugins -> "plugins:$pubkey:$agent" is Mcp -> "mcp:$pubkey:$agent" + is Providers -> "providers:$pubkey:$agent" Appearance -> "appearance" Notifications -> "notifications" Connection -> "connection" @@ -115,7 +118,7 @@ internal sealed interface SettingsPage { val depth: Int get() = when (this) { Hub -> 0 - is Plugins, is Mcp -> 2 + is Plugins, is Mcp, is Providers -> 2 else -> 1 } @@ -124,6 +127,7 @@ internal sealed interface SettingsPage { saved.startsWith("machine:") -> Machine(saved.removePrefix("machine:")) saved.startsWith("plugins:") -> saved.split(':').let { Plugins(it[1], it.drop(2).joinToString(":")) } saved.startsWith("mcp:") -> saved.split(':').let { Mcp(it[1], it.drop(2).joinToString(":")) } + saved.startsWith("providers:") -> saved.split(':').let { Providers(it[1], it.drop(2).joinToString(":")) } saved == "appearance" -> Appearance saved == "notifications" -> Notifications saved == "connection" -> Connection @@ -227,6 +231,7 @@ fun SettingsScreen( now = System.currentTimeMillis(), dispatch = ::dispatch, onBack = toHub, + onOpenProviders = { agent -> open(SettingsPage.Providers(machine.pubkeyHex, agent)) }, onOpenPlugins = { agent -> open(SettingsPage.Plugins(machine.pubkeyHex, agent)) }, onOpenMcp = { agent -> open(SettingsPage.Mcp(machine.pubkeyHex, agent)) }, ) @@ -234,6 +239,7 @@ fun SettingsScreen( } is SettingsPage.Plugins -> PluginsScreen(core, page.pubkey, page.agent, onBack = back) is SettingsPage.Mcp -> McpScreen(core, page.pubkey, page.agent, onBack = back) + is SettingsPage.Providers -> ProvidersScreen(core, page.pubkey, page.agent, onBack = back) SettingsPage.Appearance -> AppearancePage(view, ::dispatch, toHub) SettingsPage.Notifications -> NotificationsPage(view, ::dispatch, toHub) SettingsPage.Connection -> { diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 740cc1b5..7897d0b9 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -4458,6 +4458,11 @@ data class UniffiAgent ( , var `supportsProviders`: kotlin.Boolean , + /** + * The agent's provider profiles add models to its own list. + */ + var `supportsProviderModels`: kotlin.Boolean + , var `supportsGsd`: kotlin.Boolean , var `supportsInterrupt`: kotlin.Boolean @@ -4505,6 +4510,7 @@ public object FfiConverterTypeUniffiAgent: FfiConverterRustBuffer { FfiConverterBoolean.read(buf), FfiConverterBoolean.read(buf), FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), FfiConverterSequenceTypeUniffiCredentialStatus.read(buf), ) } @@ -4519,6 +4525,7 @@ public object FfiConverterTypeUniffiAgent: FfiConverterRustBuffer { FfiConverterBoolean.allocationSize(value.`supportsModels`) + FfiConverterBoolean.allocationSize(value.`supportsUsage`) + FfiConverterBoolean.allocationSize(value.`supportsProviders`) + + FfiConverterBoolean.allocationSize(value.`supportsProviderModels`) + FfiConverterBoolean.allocationSize(value.`supportsGsd`) + FfiConverterBoolean.allocationSize(value.`supportsInterrupt`) + FfiConverterBoolean.allocationSize(value.`supportsCommands`) + @@ -4538,6 +4545,7 @@ public object FfiConverterTypeUniffiAgent: FfiConverterRustBuffer { FfiConverterBoolean.write(value.`supportsModels`, buf) FfiConverterBoolean.write(value.`supportsUsage`, buf) FfiConverterBoolean.write(value.`supportsProviders`, buf) + FfiConverterBoolean.write(value.`supportsProviderModels`, buf) FfiConverterBoolean.write(value.`supportsGsd`, buf) FfiConverterBoolean.write(value.`supportsInterrupt`, buf) FfiConverterBoolean.write(value.`supportsCommands`, buf) @@ -6775,12 +6783,23 @@ public object FfiConverterTypeUniffiProviderProfileAck: FfiConverterRustBuffer , + /** + * The models are the provider's own list. + */ + var `modelsFromProvider`: kotlin.Boolean + , var `defaultModel`: kotlin.String? , var `hasToken`: kotlin.Boolean @@ -6803,7 +6822,9 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), + FfiConverterString.read(buf), FfiConverterSequenceTypeUniffiModelEntry.read(buf), + FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), FfiConverterBoolean.read(buf), ) @@ -6811,18 +6832,22 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< override fun allocationSize(value: UniffiProviderProfileInfo) = ( FfiConverterString.allocationSize(value.`id`) + + FfiConverterString.allocationSize(value.`agent`) + FfiConverterString.allocationSize(value.`label`) + FfiConverterString.allocationSize(value.`baseUrl`) + FfiConverterSequenceTypeUniffiModelEntry.allocationSize(value.`models`) + + FfiConverterBoolean.allocationSize(value.`modelsFromProvider`) + FfiConverterOptionalString.allocationSize(value.`defaultModel`) + FfiConverterBoolean.allocationSize(value.`hasToken`) ) override fun write(value: UniffiProviderProfileInfo, buf: ByteBuffer) { FfiConverterString.write(value.`id`, buf) + FfiConverterString.write(value.`agent`, buf) FfiConverterString.write(value.`label`, buf) FfiConverterString.write(value.`baseUrl`, buf) FfiConverterSequenceTypeUniffiModelEntry.write(value.`models`, buf) + FfiConverterBoolean.write(value.`modelsFromProvider`, buf) FfiConverterOptionalString.write(value.`defaultModel`, buf) FfiConverterBoolean.write(value.`hasToken`, buf) } @@ -6834,6 +6859,8 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< * UniFFI-crossable mirror of [`protocol::commands::ProviderProfileWrite`]. */ data class UniffiProviderProfileWrite ( + var `agent`: kotlin.String + , var `label`: kotlin.String , var `baseUrl`: kotlin.String @@ -6842,6 +6869,12 @@ data class UniffiProviderProfileWrite ( , var `models`: List , + /** + * The bridge reads the models from the provider instead (`models` is + * then ignored). + */ + var `modelsFromProvider`: kotlin.Boolean + , var `defaultModel`: kotlin.String? ){ @@ -6859,27 +6892,33 @@ data class UniffiProviderProfileWrite ( public object FfiConverterTypeUniffiProviderProfileWrite: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UniffiProviderProfileWrite { return UniffiProviderProfileWrite( + FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterTypeUniffiTristate.read(buf), FfiConverterSequenceTypeUniffiProviderModelWrite.read(buf), + FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), ) } override fun allocationSize(value: UniffiProviderProfileWrite) = ( + FfiConverterString.allocationSize(value.`agent`) + FfiConverterString.allocationSize(value.`label`) + FfiConverterString.allocationSize(value.`baseUrl`) + FfiConverterTypeUniffiTristate.allocationSize(value.`authToken`) + FfiConverterSequenceTypeUniffiProviderModelWrite.allocationSize(value.`models`) + + FfiConverterBoolean.allocationSize(value.`modelsFromProvider`) + FfiConverterOptionalString.allocationSize(value.`defaultModel`) ) override fun write(value: UniffiProviderProfileWrite, buf: ByteBuffer) { + FfiConverterString.write(value.`agent`, buf) FfiConverterString.write(value.`label`, buf) FfiConverterString.write(value.`baseUrl`, buf) FfiConverterTypeUniffiTristate.write(value.`authToken`, buf) FfiConverterSequenceTypeUniffiProviderModelWrite.write(value.`models`, buf) + FfiConverterBoolean.write(value.`modelsFromProvider`, buf) FfiConverterOptionalString.write(value.`defaultModel`, buf) } } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt index 9bae6011..66005fe8 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt @@ -35,6 +35,7 @@ import uniffi.client_ffi.UniffiPluginNotice import uniffi.client_ffi.UniffiCredentialStatus import uniffi.client_ffi.UniffiMachineSummary import uniffi.client_ffi.UniffiModelEntry +import uniffi.client_ffi.UniffiProviderProfileInfo import uniffi.client_ffi.UniffiOptionChoice import uniffi.client_ffi.UniffiQuickPrompt import uniffi.client_ffi.UniffiSessionCommands @@ -72,19 +73,43 @@ internal object DesignFixtures { modes = listOf(UniffiOptionChoice("default", "Ask first", null), UniffiOptionChoice("acceptEdits", "Accept edits", null), UniffiOptionChoice("plan", "Plan", null)), efforts = listOf(UniffiOptionChoice("low", "Low", null), UniffiOptionChoice("high", "High", null)), defaultMode = "default", defaultEffort = null, - supportsModels = true, supportsUsage = true, supportsProviders = true, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, + supportsModels = true, supportsUsage = true, supportsProviders = true, supportsProviderModels = false, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, credentials = listOf(UniffiCredentialStatus("oauth", "Claude token", present = true, fromEnv = false, valid = true)), ) val opencode = UniffiAgent( id = "opencode", displayName = "OpenCode", modes = listOf(UniffiOptionChoice("build", "Build", null), UniffiOptionChoice("plan", "Plan", null)), efforts = emptyList(), defaultMode = "build", defaultEffort = null, - supportsModels = true, supportsUsage = false, supportsProviders = false, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, + supportsModels = true, supportsUsage = false, supportsProviders = false, supportsProviderModels = true, supportsGsd = false, supportsInterrupt = true, supportsCommands = true, supportsPlugins = true, supportsMcp = true, supportsTasks = true, credentials = emptyList(), ) private const val OFFICIAL = "claude-plugins-official" + /** A gateway at home and OpenRouter for OpenCode, their models read from + * each; and one saved before providers named their agent. */ + val providerProfiles = listOf( + UniffiProviderProfileInfo( + id = "home-gateway", agent = "opencode", label = "Home gateway", baseUrl = "http://192.168.1.2:3458", + models = listOf( + UniffiModelEntry("OpenCode Go/deepseek-v4.1-flash", null, "Home gateway"), + UniffiModelEntry("Z.ai/glm-5.3-flash", null, "Home gateway"), + UniffiModelEntry("qwen3.8-coder", null, "Home gateway"), + ), + modelsFromProvider = true, defaultModel = "OpenCode Go/deepseek-v4.1-flash", hasToken = true, + ), + UniffiProviderProfileInfo( + id = "openrouter", agent = "opencode", label = "OpenRouter", baseUrl = "https://openrouter.ai/api", + models = (1..42).map { UniffiModelEntry("vendor/model-$it", null, "OpenRouter") }, + modelsFromProvider = true, defaultModel = null, hasToken = false, + ), + UniffiProviderProfileInfo( + id = "kimi-k3", agent = "", label = "Kimi K3", baseUrl = "https://api.moonshot.ai/anthropic", + models = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Kimi K3")), + modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true, + ), + ) + /** Claude Code's plugins: two installed (one off), two marketplaces, an * install under way, and a change that failed. */ val claudePlugins = UniffiAgentPlugins( @@ -170,7 +195,7 @@ internal object DesignFixtures { "opus", null, )), - providerProfiles = emptyList(), plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp), + providerProfiles = providerProfiles, plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp), directAdvertised = listOf("wss://192.168.1.20:7447"), directPinned = true, directEndpoints = listOf("wss://workstation.tail1234.ts.net:7447"), directUp = "wss://192.168.1.20:7447", npub = "npub1q8zy7gyw0l9fh2qkj6x4wlcw5h6xyq9d0k3e8w2yv3m5l6n7p8r9s0tuvw", diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 1d710ba9..1ae96244 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -28,6 +28,8 @@ import com.codedeck.plus.ui.screens.NewSessionBody import com.codedeck.plus.ui.screens.NotificationsPage import com.codedeck.plus.ui.screens.PairingBody import com.codedeck.plus.ui.screens.PluginsContent +import com.codedeck.plus.ui.screens.ProviderEditor +import com.codedeck.plus.ui.screens.ProvidersContent import com.codedeck.plus.ui.screens.UploadsPage import com.codedeck.plus.ui.transcript.DisplayEntry import com.codedeck.plus.ui.screens.AccountPage @@ -175,7 +177,7 @@ private val pages: Map Unit> = linkedMapOf( "settings_machine" to { MachineSettingsContent( machine = workstation, connectedRelays = setOf("wss://relay.example.org"), credentialsStatus = null, - providerProfileStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenPlugins = {}, onOpenMcp = {}, + providerProfileStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenProviders = {}, onOpenPlugins = {}, onOpenMcp = {}, ) }, "plugins" to { PluginsContent(workstation, "claude-code", dispatch = {}, onBack = {}) }, @@ -197,6 +199,12 @@ private val pages: Map Unit> = linkedMapOf( androidx.compose.foundation.layout.Box(Modifier.background(Tokens.SurfaceRaised)) { SessionMcpList(DesignFixtures.sessionMcp) { _, _ -> } } }, "plugins_opencode" to { PluginsContent(workstation, "opencode", dispatch = {}, onBack = {}) }, + "providers_opencode" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}) }, + "providers_claude_empty" to { ProvidersContent(workstation, "claude-code", status = null, dispatch = {}, onBack = {}) }, + "providers_edit" to { + ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}, startEditing = ProviderEditor.Existing("home-gateway"), validBaseUrl = { true }) + }, + "providers_add" to { ProvidersContent(workstation, "claude-code", status = null, dispatch = {}, onBack = {}, startEditing = ProviderEditor.New, validBaseUrl = { true }) }, "settings_appearance" to { AppearancePage(settings, {}, {}) }, "settings_notifications" to { NotificationsPage(settings, {}, {}) }, "settings_connection" to { ConnectionPage(settings, serviceForeground = true, dispatch = {}, onBack = {}) }, @@ -281,6 +289,10 @@ class DesignSnapshotTest { @Test fun mcp() = paparazzi.page("mcp") @Test fun mcp_add() = paparazzi.page("mcp_add") @Test fun mcp_import() = paparazzi.page("mcp_import") + @Test fun providers_opencode() = paparazzi.page("providers_opencode") + @Test fun providers_claude_empty() = paparazzi.page("providers_claude_empty") + @Test fun providers_edit() = paparazzi.page("providers_edit") + @Test fun providers_add() = paparazzi.page("providers_add") @Test fun session_mcp() = paparazzi.page("session_mcp") @Test fun settings_appearance() = paparazzi.page("settings_appearance") @Test fun settings_notifications() = paparazzi.page("settings_notifications") diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_add.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_add.png new file mode 100644 index 00000000..47a79888 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_add.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_claude_empty.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_claude_empty.png new file mode 100644 index 00000000..86036af5 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_claude_empty.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_edit.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_edit.png new file mode 100644 index 00000000..936cbf11 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_edit.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png new file mode 100644 index 00000000..b1c5ff50 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png differ diff --git a/crates/agent-protocol/src/codec.rs b/crates/agent-protocol/src/codec.rs index f850fa75..cb043e4b 100644 --- a/crates/agent-protocol/src/codec.rs +++ b/crates/agent-protocol/src/codec.rs @@ -82,20 +82,25 @@ impl BridgeMessage { impl HostMessage { /// Whether this message answers a bridge request (as opposed to asking - /// or notifying). + /// or notifying). Every kind is named, so a new one cannot be left out + /// unnoticed. pub fn is_reply(&self) -> bool { - matches!( - self, + match self { Self::Initialized { .. } - | Self::Ack - | Self::Error { .. } - | Self::Models { .. } - | Self::Usage { .. } - | Self::Commands { .. } - | Self::Plugins { .. } - | Self::McpServers { .. } - | Self::SessionMcp { .. } - | Self::CredentialChecked { .. } - ) + | Self::Ack + | Self::Error { .. } + | Self::Models { .. } + | Self::Usage { .. } + | Self::Commands { .. } + | Self::Plugins { .. } + | Self::McpServers { .. } + | Self::SessionMcp { .. } + | Self::CredentialChecked { .. } + | Self::ProviderModels { .. } => true, + Self::SessionEvent { .. } + | Self::RequestPermission(_) + | Self::AskQuestion(_) + | Self::RequestPlanApproval(_) => false, + } } } diff --git a/crates/agent-protocol/src/lib.rs b/crates/agent-protocol/src/lib.rs index b76b6297..08fb8faa 100644 --- a/crates/agent-protocol/src/lib.rs +++ b/crates/agent-protocol/src/lib.rs @@ -56,7 +56,7 @@ mod tests { let start = bridge_rt(json!({"v":1,"id":"2","kind":"start-session","payload":{ "sessionId":"s1","agent":"claude-code","cwd":"/w","mode":"plan","effort":"high","model":"m", "resume":"native-1","credentials":{"anthropic_api_key":"sk"},"env":{"GITHUB_TOKEN":"gh"}, - "provider":{"id":"p","baseUrl":"https://x","authToken":"t","models":[{"id":"k"}],"defaultModel":"k"} + "provider":{"id":"p","label":"P","baseUrl":"https://x","authToken":"t","models":[{"id":"k"}],"defaultModel":"k"} }})); let BridgeMessage::StartSession(s) = start.message else { panic!("start-session") }; assert_eq!(s.credentials["anthropic_api_key"].expose(), "sk"); @@ -83,6 +83,12 @@ mod tests { bridge_rt(json!({"v":1,"id":"16","kind":"session-mcp","payload":{"sessionId":"s"}})); bridge_rt(json!({"v":1,"id":"17","kind":"session-mcp-toggle","payload":{"sessionId":"s","name":"gh","enabled":false}})); bridge_rt(json!({"v":1,"id":"10","kind":"check-credential","payload":{"agent":"claude-code","credential":"anthropic_api_key","value":"sk"}})); + bridge_rt(json!({"v":1,"id":"18","kind":"check-provider","payload":{"agent":"opencode","model":"m1", + "provider":{"id":"or","label":"OpenRouter","baseUrl":"https://openrouter.ai/api","authToken":"tok","models":[{"id":"m1"}]}}})); + bridge_rt(json!({"v":1,"id":"19","kind":"list-provider-models","payload":{"agent":"opencode","baseUrl":"https://openrouter.ai/api","authToken":"tok"}})); + bridge_rt(json!({"v":1,"id":"20","kind":"set-providers","payload":{"agent":"opencode","providers":[ + {"id":"or","label":"OpenRouter","baseUrl":"https://openrouter.ai/api","authToken":"tok","models":[{"id":"a/b","label":"B"}],"defaultModel":"a/b"}]}})); + bridge_rt(json!({"v":1,"id":"21","kind":"set-providers","payload":{"agent":"opencode","providers":[]}})); bridge_rt(json!({"v":1,"id":"h1","kind":"permission-outcome","payload":{"outcome":"selected","optionId":"allow"}})); bridge_rt(json!({"v":1,"id":"h2","kind":"plan-outcome","payload":{"outcome":"cancelled","reason":"Timed out"}})); bridge_rt(json!({"v":1,"id":"h4","kind":"plan-outcome","payload":{"outcome":"selected","optionId":"revise","feedback":"Fewer steps."}})); @@ -95,11 +101,11 @@ mod tests { host_rt(json!({"v":1,"id":"1","kind":"initialized","payload":{"hostVersion":"1","agents":[{ "id":"claude-code","displayName":"Claude Code", "modes":[{"id":"plan","label":"Plan"}],"efforts":[],"defaultMode":"default", - "supports":{"models":true,"usage":true,"providers":true,"gsd":true,"interrupt":true,"commands":true,"plugins":true,"mcp":true,"tasks":true}, + "supports":{"models":true,"usage":true,"providers":true,"providerModels":false,"gsd":true,"interrupt":true,"commands":true,"plugins":true,"mcp":true,"tasks":true}, "credentials":[{"id":"anthropic_api_key","label":"Anthropic API key","envVar":"ANTHROPIC_API_KEY"}] },{ "id":"opencode","displayName":"OpenCode","modes":[],"efforts":[], - "supports":{"models":false,"usage":false,"providers":false,"gsd":false,"interrupt":true,"commands":false,"plugins":false,"mcp":false,"tasks":false}, + "supports":{"models":false,"usage":false,"providers":false,"providerModels":true,"gsd":false,"interrupt":true,"commands":false,"plugins":false,"mcp":false,"tasks":false}, "credentials":[],"unavailableReason":"opencode is not installed" }]}})); host_rt(json!({"v":1,"id":"2","kind":"ack"})); @@ -113,6 +119,7 @@ mod tests { host_rt(json!({"v":1,"id":"6","kind":"usage","payload":{"usage":{"available":true,"windows":[{"label":"5h","utilization":12.5,"resetsAt":null}],"fetchedAt":"t"}}})); host_rt(json!({"v":1,"id":"7","kind":"credential-checked","payload":{"valid":false}})); host_rt(json!({"v":1,"id":"8","kind":"credential-checked","payload":{}})); + host_rt(json!({"v":1,"id":"9","kind":"provider-models","payload":{"models":[{"id":"a/b","label":"B"},{"id":"c"}]}})); host_rt(json!({"v":1,"kind":"session-event","payload":{"sessionId":"s","event":{"type":"ready"}}})); host_rt(json!({"v":1,"kind":"session-event","payload":{"sessionId":"s","event":{ "type":"info","nativeSessionId":"n","model":"m","mode":"plan","contextWindow":1000000,"contextPercentage":12.0}}})); @@ -206,7 +213,7 @@ mod tests { &json!({"v":1,"id":"1","kind":"start-session","payload":{ "sessionId":"s","agent":"claude-code","cwd":"/w", "credentials":{"anthropic_api_key":"sk-ant-SECRET"},"env":{"GITHUB_TOKEN":"ghp_SECRET"}, - "provider":{"id":"p","baseUrl":"https://x","authToken":"tok-SECRET","models":[]} + "provider":{"id":"p","label":"P","baseUrl":"https://x","authToken":"tok-SECRET","models":[]} }}) .to_string(), ) diff --git a/crates/agent-protocol/src/messages.rs b/crates/agent-protocol/src/messages.rs index b28403d1..e2db4d1c 100644 --- a/crates/agent-protocol/src/messages.rs +++ b/crates/agent-protocol/src/messages.rs @@ -115,11 +115,14 @@ impl From for McpServerAdd { // --- starting a session --- -/// A custom provider profile a session is bound to for its whole life. +/// A provider profile, token included: one a session is bound to for its +/// whole life, or one an agent offers the models of (`set-providers`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProviderBinding { pub id: String, + /// The name the user gave it, for showing where a model comes from. + pub label: String, pub base_url: String, pub auth_token: Secret, pub models: Vec, @@ -395,6 +398,30 @@ pub enum BridgeMessage { credential: String, value: Secret, }, + /// Check a provider profile's token with the endpoint, the way `agent` + /// would use it, on `model`. Reply: `credential-checked`. + CheckProvider { + agent: String, + provider: ProviderBinding, + model: String, + }, + /// The models the endpoint at `base_url` lists, read with `auth_token` + /// the way `agent` speaks to it (a provider profile of `agent` being + /// saved). Reply: `provider-models`, or `error` saying why there is no + /// list. + ListProviderModels { + agent: String, + base_url: String, + auth_token: Secret, + }, + /// The provider profiles of an agent whose catalog entry `supports` + /// `providerModels`, all of them: sent after `initialize` and whenever + /// one changes. The agent offers their models beside its own. Reply: + /// `ack`. + SetProviders { + agent: String, + providers: Vec, + }, /// Reply to `request-permission`. PermissionOutcome(SelectOutcome), /// Reply to `request-plan-approval`. @@ -459,11 +486,14 @@ pub enum HostMessage { #[serde(default, skip_serializing_if = "std::ops::Not::not")] project_wide: bool, }, - /// Reply to `check-credential`; absent `valid` = it could not be checked. + /// Reply to `check-credential` and `check-provider`; absent `valid` = it + /// could not be checked. CredentialChecked { #[serde(default, skip_serializing_if = "Option::is_none")] valid: Option, }, + /// Reply to `list-provider-models`: never empty. + ProviderModels { models: Vec }, /// A notification: no frame id, no reply. SessionEvent { session_id: String, diff --git a/crates/bridge-core/src/catalog.rs b/crates/bridge-core/src/catalog.rs index 4103c57f..52fce9fe 100644 --- a/crates/bridge-core/src/catalog.rs +++ b/crates/bridge-core/src/catalog.rs @@ -23,12 +23,22 @@ impl Catalog { self.agents.iter().find(|a| a.id == id) } - /// The agent, when sessions can run on it; otherwise why not. - pub fn usable(&self, id: &str) -> Result<&AgentInfo, String> { + pub fn all(&self) -> &[AgentInfo] { + &self.agents + } + + /// The agent, when this bridge has it (usable now or not); otherwise why + /// it cannot say. + pub fn known(&self, id: &str) -> Result<&AgentInfo, String> { if !self.known { return Err("The agent host is not running yet — try again in a moment.".into()); } - let agent = self.get(id).ok_or_else(|| format!("This bridge has no agent '{id}'."))?; + self.get(id).ok_or_else(|| format!("This bridge has no agent '{id}'.")) + } + + /// The agent, when sessions can run on it; otherwise why not. + pub fn usable(&self, id: &str) -> Result<&AgentInfo, String> { + let agent = self.known(id)?; match &agent.unavailable_reason { Some(reason) => Err(reason.clone()), None => Ok(agent), diff --git a/crates/bridge-core/src/engine/host.rs b/crates/bridge-core/src/engine/host.rs index 31923e39..79b57e9d 100644 --- a/crates/bridge-core/src/engine/host.rs +++ b/crates/bridge-core/src/engine/host.rs @@ -55,6 +55,11 @@ pub(crate) enum HostCall { /// A session's MCP status, asked for or answering a toggle. SessionMcp { session_id: String }, CheckCredential { ticket: u64, agent: String, credential: String, value: agent_protocol::Secret }, + /// A provider profile's token check, for the save waiting on it. + CheckProvider { ticket: u64 }, + /// A provider's model list, for the save waiting on it. + ListProviderModels { ticket: u64 }, + SetProviders { agent: String }, DeleteConversation(ConversationDelete), } @@ -192,6 +197,7 @@ impl Engine { for delete in std::mem::take(&mut self.host.deletes) { self.delete_conversation(delete); } + self.push_all_providers(); } Ok(_) => log::error!("[Engine] The agent host answered initialize with the wrong reply"), Err(err) => log::error!("[Engine] The agent host failed to initialize: {err}"), @@ -233,6 +239,26 @@ impl Engine { }; self.on_credential_checked(ticket, &agent, &credential, &value, valid); } + HostCall::CheckProvider { ticket } => { + let valid = match result { + Ok(HostMessage::CredentialChecked { valid }) => valid, + _ => None, + }; + self.on_provider_token_checked(ticket, valid); + } + HostCall::ListProviderModels { ticket } => { + let models = match result { + Ok(HostMessage::ProviderModels { models }) => Ok(models), + Ok(_) => Err("the agent host gave no model list".to_string()), + Err(err) => Err(err), + }; + self.on_provider_models_fetched(ticket, models); + } + HostCall::SetProviders { agent } => { + if let Err(err) = result { + log::warn!("[Engine] {agent} did not take its provider profiles: {err}"); + } + } HostCall::DeleteConversation(delete) => match result { Ok(_) => log::info!("[Engine] Conversation {} of {} deleted", delete.conversation_id, delete.session_id), Err(err) => log::warn!( @@ -430,7 +456,7 @@ impl Engine { // A bound profile is looked up now, at every start, so a rotated // token reaches restarts — and a deleted or insecure one refuses the // start instead of silently falling back to the agent's own account. - let provider = rec.provider_id.as_deref().map(|id| self.provider_binding(id)).transpose()?; + let provider = rec.provider_id.as_deref().map(|id| self.provider_binding(id, &rec.agent)).transpose()?; let env = self .credentials .get(None, GITHUB_PAT) diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index f4774d10..a63a6dbe 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -148,6 +148,9 @@ pub struct Engine { credential_acks: BTreeMap, /// set-provider-profile waiting on the token check: ticket → (phone, profile id). profile_acks: BTreeMap, + /// set-provider-profile waiting on the provider's model list: ticket → + /// (phone, the profile to store once its models are in). + model_fetches: BTreeMap, next_ticket: u64, sync: SyncServer, pairing: Option, @@ -191,6 +194,7 @@ impl Engine { profiles: BTreeMap::new(), credential_acks: BTreeMap::new(), profile_acks: BTreeMap::new(), + model_fetches: BTreeMap::new(), next_ticket: 0, sync, pairing: None, @@ -225,7 +229,6 @@ impl Engine { Input::Gsd { session_id, gsd } => { self.publish_all(BridgeToPhone::GsdState(protocol::events::GsdStateMsg { session_id, gsd })); } - Input::ProviderTokenChecked { ticket, valid } => self.on_provider_token_checked(ticket, valid), Input::FileReady { session_id, text } => { if !self.send_input(&session_id, text) { log::warn!("[Engine] Uploaded image for {session_id} could not be delivered: no live session"); diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 7f3c86e9..a059503f 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -298,6 +298,9 @@ impl Engine { } else { match profile { None => Some(format!("Unknown provider profile '{id}' — it may have been deleted on this machine.")), + Some(p) if p.agent != agent.id => { + Some(format!("Provider profile '{}' is not one of {}'s.", p.label, agent.display_name)) + } Some(p) if !is_valid_provider_base_url(&p.base_url) => Some(format!( "Provider profile '{}' has an insecure base URL ({}) — {PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext. Edit the profile in Settings and save it again.", p.label, p.base_url diff --git a/crates/bridge-core/src/engine/settings.rs b/crates/bridge-core/src/engine/settings.rs index 825638f6..d9bcae1b 100644 --- a/crates/bridge-core/src/engine/settings.rs +++ b/crates/bridge-core/src/engine/settings.rs @@ -6,12 +6,12 @@ use agent_protocol::{AgentInfo, BridgeMessage, ProviderBinding, Secret}; use protocol::commands::{SetCredentialsMsg, SetProviderProfileMsg}; -use protocol::common::{is_valid_provider_base_url, CredentialStatus, PROVIDER_BASE_URL_ERROR}; +use protocol::common::{is_valid_provider_base_url, CredentialStatus, ProviderModel, PROVIDER_BASE_URL_ERROR}; use protocol::events::{BridgeToPhone, CredentialsAckMsg, ProviderProfileAckMsg, ProviderProfilesMsg}; use protocol::tristate::Tristate; use super::{Engine, HostCall}; -use crate::io::{store_keys, Effect}; +use crate::io::store_keys; use crate::settings::{ProviderProfile, StoredProfiles, GITHUB_PAT, GITHUB_PAT_LABEL}; /// A `credentials-ack` waiting on credential checks. @@ -186,26 +186,22 @@ impl Engine { // --- provider profiles --- - /// What a session bound to profile `id` starts with. Refuses a deleted - /// profile, an insecure base URL and a missing token: each would - /// otherwise start the session on the wrong account or put the token on - /// a cleartext connection. - pub(super) fn provider_binding(&self, id: &str) -> Result { + /// What a session of `agent` bound to profile `id` starts with. Refuses + /// a deleted profile, another agent's, an insecure base URL and a + /// missing token: each would otherwise start the session on the wrong + /// account or endpoint, or put the token on a cleartext connection. + pub(super) fn provider_binding(&self, id: &str, agent: &str) -> Result { let profile = self.profiles.get(id).ok_or_else(|| format!("provider profile '{id}' was deleted"))?; + if profile.agent != agent { + return Err(format!("provider profile '{id}' is no longer one of {agent}'s")); + } if !is_valid_provider_base_url(&profile.base_url) { return Err(format!( "provider profile '{id}' has an insecure base URL ({}) — {PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext, so the session is refused.", profile.base_url )); } - let auth_token = profile.auth_token.clone().ok_or_else(|| format!("provider profile '{id}' has no stored auth token"))?; - Ok(ProviderBinding { - id: id.to_string(), - base_url: profile.base_url.clone(), - auth_token, - models: profile.models.clone(), - default_model: profile.default_model.clone(), - }) + profile.binding().ok_or_else(|| format!("provider profile '{id}' has no stored auth token")) } pub(super) fn provider_profiles_msg(&self) -> BridgeToPhone { @@ -215,33 +211,75 @@ impl Engine { }) } - /// Create, update or delete one profile. An upsert with a token checks it - /// against the profile's own endpoint before acking; every phone then + /// Hand an agent whose catalog entry `supports.provider_models` its + /// profiles — every usable one, so a deleted or emptied profile goes + /// away too. + pub(super) fn push_providers(&mut self, agent: &str) { + if !self.host.initialized || !self.catalog.get(agent).is_some_and(|a| a.supports.provider_models) { + return; + } + let providers: Vec = self + .profiles + .values() + .filter(|p| p.agent == agent && is_valid_provider_base_url(&p.base_url)) + .filter_map(ProviderProfile::binding) + .collect(); + log::info!("[Engine] {agent} gets {} provider profile(s)", providers.len()); + let message = BridgeMessage::SetProviders { agent: agent.to_string(), providers }; + self.call(HostCall::SetProviders { agent: agent.to_string() }, message); + } + + /// Every agent's profiles, once the host is (back) up. + pub(super) fn push_all_providers(&mut self) { + let agents: Vec = + self.catalog.all().iter().filter(|a| a.supports.provider_models).map(|a| a.id.clone()).collect(); + for agent in agents { + self.push_providers(&agent); + } + } + + /// Create, update or delete one profile. An upsert whose models come + /// from the provider reads them first; one with a token then has it + /// checked, the way its agent uses it, before acking; every phone then /// gets the new (redacted) list. pub(super) fn on_set_provider_profile(&mut self, m: SetProviderProfileMsg, phone: &str) { let id = m.profile_id; log::info!("[Engine] set-provider-profile '{id}' from {}...", phone.get(..8).unwrap_or(phone)); - let mut next = self.profiles.clone(); + // The newest write of a profile is the one that stands: a model list + // still on its way for an older one is dropped. + self.model_fetches.retain(|_, (_, pending)| pending.id != id); let Some(write) = m.profile else { // Deleting is never refused, whatever the stored URL: a profile // that needs fixing must stay removable. Sessions bound to it // fail at their next start rather than fall back to another // account. - let existed = next.remove(&id).is_some(); + let mut next = self.profiles.clone(); + let removed = next.remove(&id); if let Err(err) = self.store_profiles(next) { return self.send_profile_ack(phone, &id, Err(err)); } - log::info!("[Engine] Provider profile '{id}' deleted (existed={existed})"); + log::info!("[Engine] Provider profile '{id}' deleted (existed={})", removed.is_some()); self.send_profile_ack(phone, &id, Ok(None)); + if let Some(removed) = removed { + self.push_providers(&removed.agent); + } return; }; - // The store never holds an insecure profile: the token check below - // sends the token to this URL. + // The store never holds an insecure profile: the token check and the + // model list send the token to this URL. if !is_valid_provider_base_url(&write.base_url) { log::info!("[Engine] Provider profile '{id}' refused: insecure base URL ({})", write.base_url); return self.send_profile_ack(phone, &id, Err(PROVIDER_BASE_URL_ERROR.into())); } - if write.models.is_empty() { + match self.catalog.known(&write.agent) { + Ok(agent) if agent.supports.providers || agent.supports.provider_models => {} + Ok(agent) => { + let reason = format!("{} takes no provider profiles.", agent.display_name); + return self.send_profile_ack(phone, &id, Err(reason)); + } + Err(reason) => return self.send_profile_ack(phone, &id, Err(reason)), + } + if !write.models_from_provider && write.models.is_empty() { return self.send_profile_ack(phone, &id, Err("A provider profile needs at least one model.".into())); } let auth_token = match write.auth_token { @@ -251,33 +289,91 @@ impl Engine { }; let profile = ProviderProfile { id: id.clone(), + agent: write.agent, label: write.label, base_url: write.base_url, auth_token, - models: write.models, + models: if write.models_from_provider { Vec::new() } else { write.models }, + models_from_provider: write.models_from_provider, default_model: write.default_model, updated_at: Some(self.now_iso()), }; - next.insert(id.clone(), profile.clone()); + if !profile.models_from_provider { + return self.save_profile(phone, profile); + } + let Some(token) = profile.auth_token.clone() else { + return self.send_profile_ack(phone, &id, Err("Reading the provider's models needs its API token.".into())); + }; + self.next_ticket += 1; + let ticket = self.next_ticket; + let message = + BridgeMessage::ListProviderModels { agent: profile.agent.clone(), base_url: profile.base_url.clone(), auth_token: token }; + if self.call(HostCall::ListProviderModels { ticket }, message).is_none() { + let reason = "The agent host is not running, so the provider's models cannot be read now.".to_string(); + return self.send_profile_ack(phone, &id, Err(reason)); + } + self.model_fetches.insert(ticket, (phone.to_string(), profile)); + } + + /// The provider's model list came back: store the profile with it, or + /// refuse the save when there is none to store. + pub(super) fn on_provider_models_fetched(&mut self, ticket: u64, models: Result, String>) { + let Some((phone, mut profile)) = self.model_fetches.remove(&ticket) else { return }; + let models = match models { + Ok(models) if models.is_empty() => Err("it lists no models".to_string()), + other => other, + }; + match models { + Ok(models) => { + // A default the provider no longer lists would start sessions + // on a model it cannot serve; its first one stands in. + if profile.default_model.as_ref().is_some_and(|d| !models.iter().any(|m| &m.id == d)) { + profile.default_model = None; + } + profile.models = models; + self.save_profile(&phone, profile); + } + Err(reason) => { + log::info!("[Engine] Provider profile '{}' refused: no model list from {} ({reason})", profile.id, profile.base_url); + let error = format!("Could not read the models from {}: {reason}", profile.base_url); + self.send_profile_ack(&phone, &profile.id, Err(error)); + } + } + } + + /// Store an upserted profile, hand it to its agent (and take it from the + /// agent it was for before), then have its token checked before acking. + fn save_profile(&mut self, phone: &str, profile: ProviderProfile) { + let id = profile.id.clone(); + let mut next = self.profiles.clone(); + let previous = next.insert(id.clone(), profile.clone()); if let Err(err) = self.store_profiles(next) { return self.send_profile_ack(phone, &id, Err(err)); } log::info!( - "[Engine] Provider profile saved: '{id}' (\"{}\", {}, {} model(s), hasToken={})", + "[Engine] Provider profile saved: '{id}' for {} (\"{}\", {}, {} model(s){}, hasToken={})", + profile.agent, profile.label, profile.base_url, profile.models.len(), + if profile.models_from_provider { " from the provider" } else { "" }, profile.auth_token.is_some() ); - let model = profile.fallback_model().map(str::to_string); - match (profile.auth_token, model) { - (Some(token), Some(model)) => { - self.next_ticket += 1; - let ticket = self.next_ticket; - self.profile_acks.insert(ticket, (phone.to_string(), id)); - self.out.push(Effect::CheckProviderToken { ticket, base_url: profile.base_url, token, model }); - } - _ => self.send_profile_ack(phone, &id, Ok(None)), + self.push_providers(&profile.agent); + if let Some(previous) = previous.filter(|p| p.agent != profile.agent) { + self.push_providers(&previous.agent); + } + let check = profile.binding().zip(profile.fallback_model().map(str::to_string)); + let Some((provider, model)) = check else { + return self.send_profile_ack(phone, &id, Ok(None)); + }; + self.next_ticket += 1; + let ticket = self.next_ticket; + let message = BridgeMessage::CheckProvider { agent: profile.agent.clone(), provider, model }; + if self.call(HostCall::CheckProvider { ticket }, message).is_some() { + self.profile_acks.insert(ticket, (phone.to_string(), id)); + } else { + self.send_profile_ack(phone, &id, Ok(None)); } } diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index 3151f0cf..9af51e23 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -6,7 +6,7 @@ //! further inputs. Nothing here blocks, and nothing here reads a clock the //! runtime did not provide — the same inputs always give the same effects. -use agent_protocol::{BridgeFrame, HostFrame, Secret}; +use agent_protocol::{BridgeFrame, HostFrame}; use protocol::commands::UploadFileMsg; use protocol::common::GsdState; use protocol::events::BridgeToPhone; @@ -102,9 +102,6 @@ pub enum Input { GitHead { session_id: String, head: Option }, /// Answer to [`Effect::ReadGsd`]. Gsd { session_id: String, gsd: GsdState }, - /// Answer to [`Effect::CheckProviderToken`]; `None` when it could not be - /// checked (network error). - ProviderTokenChecked { ticket: u64, valid: Option }, /// An [`Effect::HandleFileUpload`] finished: the image is on disk and /// `text` (the user's words plus its path) is the session's next input. FileReady { session_id: String, text: String }, @@ -151,15 +148,6 @@ pub enum Effect { ReadGitHead { session_id: String, cwd: String }, /// Read the GSD workflow state of `cwd`; answer with [`Input::Gsd`]. ReadGsd { session_id: String, cwd: String }, - /// Check a provider token with a one-token request to - /// `{base_url}/v1/messages` for `model`; answer with - /// [`Input::ProviderTokenChecked`]. `base_url` has passed the https rule. - CheckProviderToken { - ticket: u64, - base_url: String, - token: Secret, - model: String, - }, /// Fetch / reassemble an uploaded image into the workspace; answer with /// [`Input::FileReady`] once it is on disk. HandleFileUpload(UploadFileMsg), diff --git a/crates/bridge-core/src/settings.rs b/crates/bridge-core/src/settings.rs index ec04c7a4..7fe36262 100644 --- a/crates/bridge-core/src/settings.rs +++ b/crates/bridge-core/src/settings.rs @@ -50,18 +50,27 @@ impl StoredCredentials { } } -/// A custom provider profile: an Anthropic-compatible endpoint a session can -/// be bound to. +/// A custom provider profile: an endpoint (a provider's own API, or a +/// gateway in front of several) for one agent — a session of it can be +/// bound to the profile, or the agent offers the profile's models beside +/// its own, as its catalog entry says. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderProfile { pub id: String, + /// The agent the profile is for. Empty in a profile stored before + /// profiles had one: no agent uses it until the phone names one. + #[serde(default)] + pub agent: String, pub label: String, pub base_url: String, /// Absent = stored without a token; sessions cannot start on it. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_token: Option, pub models: Vec, + /// `models` is the provider's own list, read again on every save. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub models_from_provider: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -74,13 +83,28 @@ impl ProviderProfile { self.default_model.as_deref().or_else(|| self.models.first().map(|m| m.id.as_str())) } + /// What a session or an agent is handed: the profile with its token. + /// None without a token. + pub fn binding(&self) -> Option { + Some(agent_protocol::ProviderBinding { + id: self.id.clone(), + label: self.label.clone(), + base_url: self.base_url.clone(), + auth_token: self.auth_token.clone()?, + models: self.models.clone(), + default_model: self.default_model.clone(), + }) + } + /// The phone's view: whether a token is set, never the token. pub fn redacted(&self) -> ProviderProfileInfo { ProviderProfileInfo { id: self.id.clone(), + agent: self.agent.clone(), label: self.label.clone(), base_url: self.base_url.clone(), models: self.models.clone(), + models_from_provider: self.models_from_provider, default_model: self.default_model.clone(), has_token: self.auth_token.is_some(), } @@ -116,10 +140,12 @@ mod tests { creds.scope_mut(None).insert(GITHUB_PAT.into(), Secret::new("ghp_secret")); let profile = ProviderProfile { id: "p".into(), + agent: "claude-code".into(), label: "P".into(), base_url: "https://x".into(), auth_token: Some(Secret::new("tok-secret")), models: vec![], + models_from_provider: false, default_model: None, updated_at: None, }; @@ -131,10 +157,12 @@ mod tests { fn a_profile_redacts_to_has_token() { let profile = ProviderProfile { id: "p".into(), + agent: "claude-code".into(), label: "P".into(), base_url: "https://x".into(), auth_token: Some(Secret::new("t")), models: vec![ProviderModel { id: "m1".into(), label: None }], + models_from_provider: false, default_model: None, updated_at: None, }; diff --git a/crates/bridge-core/tests/settings.rs b/crates/bridge-core/tests/settings.rs index 2e1a75d0..10c95ae1 100644 --- a/crates/bridge-core/tests/settings.rs +++ b/crates/bridge-core/tests/settings.rs @@ -7,7 +7,7 @@ use agent_protocol::{BridgeMessage, HostMessage, SessionEvent}; use bridge_core::ports::memory::{MemoryStore, MemoryTranscripts}; use bridge_core::ports::Transcripts as _; use bridge_core::{Effect, Input, PairingCloseReason, Via}; -use protocol::common::{EntryBody, NoticeKind, OutputEntry, PROVIDER_BASE_URL_ERROR}; +use protocol::common::{EntryBody, NoticeKind, OutputEntry, ProviderModel, PROVIDER_BASE_URL_ERROR}; use protocol::crypto::generate_keypair; use protocol::events::{BridgeToPhone, PairAckReason}; use serde_json::json; @@ -186,7 +186,7 @@ fn set_profile(rig: &mut Rig, profile: serde_json::Value) { } fn kimi(token: Option<&str>) -> serde_json::Value { - let mut p = json!({"label":"Kimi","baseUrl":"https://api.kimi.test/anthropic","models":[{"id":"k2"}]}); + let mut p = json!({"agent":"alpha","label":"Kimi","baseUrl":"https://api.kimi.test/anthropic","models":[{"id":"k2"}]}); if let Some(token) = token { p["authToken"] = json!(token); } @@ -200,19 +200,23 @@ fn kimi_cleared() -> serde_json::Value { p } +/// A rig whose host is up with an agent of each kind: alpha binds sessions +/// to a profile, delta adds a profile's models to its own. +fn rig_with_providers() -> Rig { + let mut rig = Rig::new(); + rig.host_up_with(vec![alpha(), beta(), delta()]); + rig.take(); + rig +} + /// Answer the pending token check, if any; returns whether one was pending. fn answer_token_check(rig: &mut Rig, valid: Option) -> bool { - let (checks, rest): (Vec<_>, Vec<_>) = - rig.take().into_iter().partition(|e| matches!(e, Effect::CheckProviderToken { .. })); - rig.effects = rest; - let ticket = checks.iter().find_map(|e| match e { - Effect::CheckProviderToken { ticket, .. } => Some(*ticket), - _ => None, - }); - if let Some(ticket) = ticket { - rig.input(Input::ProviderTokenChecked { ticket, valid }); + if !rig.has_host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })) { + return false; } - ticket.is_some() + let (id, _) = rig.host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })); + rig.host_reply(&id, HostMessage::CredentialChecked { valid }); + true } fn has_token(rig: &mut Rig) -> Option { @@ -222,37 +226,60 @@ fn has_token(rig: &mut Rig) -> Option { }) } +fn ack_error(rig: &mut Rig) -> Option { + rig.messages().into_iter().find_map(|m| match m { + BridgeToPhone::ProviderProfileAck(a) if !a.success => a.error, + _ => None, + }) +} + #[test] -fn a_profile_is_stored_its_token_checked_and_the_list_broadcast_redacted() { - let mut rig = Rig::new(); - rig.take(); +fn a_profile_is_stored_its_token_checked_by_its_agent_and_the_list_broadcast_redacted() { + let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("tok-secret"))); - let check = rig.effects.iter().find_map(|e| match e { - Effect::CheckProviderToken { base_url, token, model, .. } => Some((base_url.clone(), token.expose().to_string(), model.clone())), - _ => None, - }); - assert_eq!(check, Some(("https://api.kimi.test/anthropic".into(), "tok-secret".into(), "k2".into()))); - answer_token_check(&mut rig, Some(false)); + let (id, msg) = rig.host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })); + match msg { + BridgeMessage::CheckProvider { agent, provider, model } => { + assert_eq!((agent.as_str(), model.as_str()), ("alpha", "k2")); + assert_eq!((provider.base_url.as_str(), provider.auth_token.expose()), ("https://api.kimi.test/anthropic", "tok-secret")); + } + _ => unreachable!(), + } + rig.host_reply(&id, HostMessage::CredentialChecked { valid: Some(false) }); let msgs = rig.messages(); assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfileAck(a) if a.success && a.token_valid == Some(false)))); - assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(p) if p.profiles[0].has_token))); + assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(p) if p.profiles[0].has_token && p.profiles[0].agent == "alpha"))); assert!(!msgs.iter().any(|m| serde_json::to_string(m).unwrap().contains("tok-secret"))); } +#[test] +fn a_profile_is_for_an_agent_that_takes_one() { + let mut rig = rig_with_providers(); + let mut p = kimi(Some("t")); + p["agent"] = json!("beta"); + set_profile(&mut rig, p); + assert_eq!(ack_error(&mut rig).as_deref(), Some("Beta takes no provider profiles.")); + let mut p = kimi(Some("t")); + p["agent"] = json!("nope"); + set_profile(&mut rig, p); + assert!(ack_error(&mut rig).is_some_and(|e| e.contains("no agent 'nope'"))); + assert!(!rig.store.snapshot().contains_key("providerProfiles")); +} + #[test] fn an_insecure_base_url_is_never_stored() { - let mut rig = Rig::new(); + let mut rig = rig_with_providers(); let mut p = kimi(Some("t")); p["baseUrl"] = json!("http://api.kimi.test"); set_profile(&mut rig, p); assert!(!answer_token_check(&mut rig, None)); - assert!(rig.messages().iter().any(|m| matches!(m, BridgeToPhone::ProviderProfileAck(a) if !a.success && a.error.as_deref() == Some(PROVIDER_BASE_URL_ERROR)))); + assert_eq!(ack_error(&mut rig).as_deref(), Some(PROVIDER_BASE_URL_ERROR)); assert!(!rig.store.snapshot().contains_key("providerProfiles")); } #[test] fn the_token_is_kept_cleared_or_replaced_and_a_profile_can_be_deleted() { - let mut rig = Rig::new(); + let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("t1"))); answer_token_check(&mut rig, Some(true)); assert_eq!(has_token(&mut rig), Some(true)); @@ -266,9 +293,148 @@ fn the_token_is_kept_cleared_or_replaced_and_a_profile_can_be_deleted() { assert!(rig.messages().iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(p) if p.profiles.is_empty()))); } -fn with_kimi() -> Rig { +/// A profile whose models come from the provider. +fn router(agent: &str, token: Option<&str>, default_model: Option<&str>) -> serde_json::Value { + let mut p = json!({"agent":agent,"label":"Router","baseUrl":"https://router.test/api","models":[],"modelsFromProvider":true}); + if let Some(token) = token { + p["authToken"] = json!(token); + } + if let Some(model) = default_model { + p["defaultModel"] = json!(model); + } + p +} + +fn set_router(rig: &mut Rig, profile: serde_json::Value) { + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":profile})); +} + +/// Take the pending model-list read: (request id, base URL, token). +fn take_model_fetch(rig: &mut Rig) -> Option<(String, String, String)> { + if !rig.has_host_request(|m| matches!(m, BridgeMessage::ListProviderModels { .. })) { + return None; + } + match rig.host_request(|m| matches!(m, BridgeMessage::ListProviderModels { .. })) { + (id, BridgeMessage::ListProviderModels { base_url, auth_token, .. }) => Some((id, base_url, auth_token.expose().to_string())), + _ => unreachable!(), + } +} + +fn listed(ids: &[&str]) -> HostMessage { + HostMessage::ProviderModels { models: ids.iter().map(|id| ProviderModel { id: (*id).into(), label: None }).collect() } +} + +fn stored_profile(rig: &mut Rig) -> Option { + rig.messages().into_iter().find_map(|m| match m { + BridgeToPhone::ProviderProfiles(p) => p.profiles.into_iter().next(), + _ => None, + }) +} + +#[test] +fn a_profile_can_take_its_models_from_the_provider() { + let mut rig = rig_with_providers(); + set_router(&mut rig, router("alpha", Some("tok-r"), Some("b"))); + let (id, base_url, token) = take_model_fetch(&mut rig).expect("the models are read first"); + assert_eq!((base_url.as_str(), token.as_str()), ("https://router.test/api", "tok-r")); + assert!(!rig.store.snapshot().contains_key("providerProfiles"), "stored only with its models"); + rig.host_reply(&id, listed(&["a", "b"])); + // Then the token is checked as for any profile, on the default model. + let (check, model) = match rig.host_request(|m| matches!(m, BridgeMessage::CheckProvider { .. })) { + (id, BridgeMessage::CheckProvider { model, .. }) => (id, model), + _ => unreachable!(), + }; + assert_eq!(model, "b"); + rig.host_reply(&check, HostMessage::CredentialChecked { valid: Some(true) }); + let info = stored_profile(&mut rig).expect("broadcast"); + assert!(info.models_from_provider); + assert_eq!(info.models.iter().map(|m| m.id.as_str()).collect::>(), ["a", "b"]); + assert_eq!(info.default_model.as_deref(), Some("b")); + assert!(rig.store.snapshot()["providerProfiles"].contains("modelsFromProvider")); +} + +#[test] +fn a_default_the_provider_no_longer_lists_is_dropped() { + let mut rig = rig_with_providers(); + set_router(&mut rig, router("alpha", Some("t"), Some("gone"))); + let (id, ..) = take_model_fetch(&mut rig).unwrap(); + rig.host_reply(&id, listed(&["a"])); + answer_token_check(&mut rig, None); + assert_eq!(stored_profile(&mut rig).unwrap().default_model, None); +} + +#[test] +fn no_model_list_means_no_save() { + let mut rig = rig_with_providers(); + // Without a token there is nothing to read the list with. + set_router(&mut rig, router("alpha", None, None)); + assert!(take_model_fetch(&mut rig).is_none()); + assert!(ack_error(&mut rig).is_some_and(|e| e.contains("needs its API token"))); + + for answer in [HostMessage::Error { message: "the provider refused the token (HTTP 401)".into() }, listed(&[])] { + set_router(&mut rig, router("alpha", Some("t"), None)); + let (id, ..) = take_model_fetch(&mut rig).unwrap(); + rig.host_reply(&id, answer); + let msgs = rig.messages(); + let error = msgs.iter().find_map(|m| match m { + BridgeToPhone::ProviderProfileAck(a) if !a.success => a.error.clone(), + _ => None, + }); + assert!(error.is_some_and(|e| e.starts_with("Could not read the models from https://router.test/api: "))); + assert!(!msgs.iter().any(|m| matches!(m, BridgeToPhone::ProviderProfiles(_)))); + } + assert!(!rig.store.snapshot().contains_key("providerProfiles")); +} + +#[test] +fn a_newer_save_wins_over_a_model_list_still_on_its_way() { + let mut rig = rig_with_providers(); + set_router(&mut rig, router("alpha", Some("t"), None)); + let (stale, ..) = take_model_fetch(&mut rig).unwrap(); + rig.send(json!({"type":"set-provider-profile","profileId":"router","profile":null})); + rig.take(); + rig.host_reply(&stale, listed(&["a"])); + assert!(rig.take().is_empty(), "the deleted profile is not brought back"); +} + +/// The providers last handed to delta, if any were. +fn pushed_to_delta(rig: &mut Rig) -> Option> { + rig.host_frames().into_iter().rev().find_map(|f| match f.message { + BridgeMessage::SetProviders { agent, providers } if agent == "delta" => Some(providers.into_iter().map(|p| p.id).collect()), + _ => None, + }) +} + +#[test] +fn an_agent_that_adds_profile_models_gets_its_profiles_whenever_they_change() { let mut rig = Rig::new(); - rig.host_up(); + rig.host_up_with(vec![alpha(), beta(), delta()]); + assert_eq!(pushed_to_delta(&mut rig), Some(vec![]), "handed its (no) profiles at start"); + + let mut p = kimi(Some("t")); + p["agent"] = json!("delta"); + set_profile(&mut rig, p.clone()); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["kimi".to_string()])); + // Moved to another agent: delta loses it. + set_profile(&mut rig, kimi(Some("t"))); + assert_eq!(pushed_to_delta(&mut rig), Some(vec![])); + set_profile(&mut rig, p); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["kimi".to_string()])); + rig.send(json!({"type":"set-provider-profile","profileId":"kimi","profile":null})); + assert_eq!(pushed_to_delta(&mut rig), Some(vec![])); + + // A host restart hands them over again. + set_profile(&mut rig, json!({"agent":"delta","label":"K","baseUrl":"https://k.test","authToken":"t","models":[{"id":"m"}]})); + rig.take(); + rig.input(Input::HostDown { reason: "test".into() }); + rig.host_up_with(vec![alpha(), beta(), delta()]); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["kimi".to_string()])); + // An agent that binds sessions is never handed any. + assert!(!rig.has_host_request(|m| matches!(m, BridgeMessage::SetProviders { agent, .. } if agent == "alpha"))); +} + +fn with_kimi() -> Rig { + let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("tok"))); answer_token_check(&mut rig, Some(true)); rig.take(); @@ -307,12 +473,18 @@ fn a_provider_bound_session_starts_on_the_live_profile_with_its_model() { } #[test] -fn a_provider_session_is_refused_for_an_unknown_profile_or_an_agent_without_providers() { +fn a_provider_session_is_refused_for_an_unknown_profile_another_agents_or_an_agent_without_providers() { let mut rig = with_kimi(); rig.send(json!({"type":"create-session","agent":"alpha","providerId":"nope"})); assert!(failed_reason(&mut rig).contains("Unknown provider profile 'nope'")); rig.send(json!({"type":"create-session","agent":"beta","providerId":"kimi"})); assert!(failed_reason(&mut rig).contains("Beta does not support custom provider profiles")); + let mut p = kimi(None); + p["agent"] = json!("delta"); + set_profile(&mut rig, p); + rig.take(); + rig.send(json!({"type":"create-session","agent":"alpha","providerId":"kimi"})); + assert!(failed_reason(&mut rig).contains("is not one of Alpha's")); set_profile(&mut rig, kimi_cleared()); rig.take(); rig.send(json!({"type":"create-session","agent":"alpha","providerId":"kimi"})); diff --git a/crates/bridge-core/tests/support/mod.rs b/crates/bridge-core/tests/support/mod.rs index 368bfb4d..60797f9f 100644 --- a/crates/bridge-core/tests/support/mod.rs +++ b/crates/bridge-core/tests/support/mod.rs @@ -34,7 +34,7 @@ pub fn alpha() -> AgentInfo { efforts: vec![choice("low"), choice("high")], default_mode: Some("ask".into()), default_effort: Some("high".into()), - supports: AgentSupports { models: true, usage: true, providers: true, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true }, + supports: AgentSupports { models: true, usage: true, providers: true, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true, ..Default::default() }, credentials: vec![CredentialSpec { id: "alpha_key".into(), label: "Alpha key".into(), env_var: Some("ALPHA_KEY".into()) }], unavailable_reason: None, } @@ -65,6 +65,16 @@ pub fn gamma() -> AgentInfo { } } +/// "delta": its provider profiles add models to its own list. +pub fn delta() -> AgentInfo { + AgentInfo { + id: "delta".into(), + display_name: "Delta".into(), + supports: AgentSupports { models: true, provider_models: true, ..Default::default() }, + ..beta() + } +} + pub struct Rig { pub engine: Engine, pub system: TestSystem, diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 7800755c..b133e95d 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -92,7 +92,6 @@ struct Runtime { host: HostHandle, inputs: mpsc::UnboundedSender, timers: HashMap>, - http: reqwest::Client, nostr_http: work::NostrHttp, outcome: Outcome, uploads: Rc>, @@ -164,7 +163,6 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio host, inputs, timers: HashMap::new(), - http: work::http_client(), nostr_http, outcome: Outcome::Stopped, uploads: Rc::new(RefCell::new(Uploads::new(&first_root))), @@ -283,13 +281,6 @@ impl Runtime { let _ = inputs.send(Input::GitHead { session_id, head }); }); } - Effect::CheckProviderToken { ticket, base_url, token, model } => { - let (inputs, http) = (self.inputs.clone(), self.http.clone()); - tokio::task::spawn_local(async move { - let valid = work::check_provider_token(&http, &base_url, token.expose(), &model).await; - let _ = inputs.send(Input::ProviderTokenChecked { ticket, valid }); - }); - } Effect::ReadGsd { session_id, cwd } => { let (inputs, gsd) = (self.inputs.clone(), Rc::clone(&self.gsd)); tokio::task::spawn_local(async move { diff --git a/crates/bridge-runtime/src/work.rs b/crates/bridge-runtime/src/work.rs index 9e605944..78806c02 100644 --- a/crates/bridge-runtime/src/work.rs +++ b/crates/bridge-runtime/src/work.rs @@ -3,8 +3,8 @@ //! bridge down. //! //! HTTP to the user's Nostr servers (a Blossom server, a relay's or Blossom -//! server's admin endpoint) goes through [`NostrHttp`]; the rest (provider -//! token checks) goes direct — the Tor proxy is for Nostr traffic. +//! server's admin endpoint) goes through [`NostrHttp`]. Provider endpoints +//! are the agent host's business, not the bridge's. use std::path::Path; use std::time::Duration; @@ -85,46 +85,15 @@ pub async fn git_head(cwd: &Path) -> Option { (out.status.success() && !head.is_empty()).then_some(head) } -/// Check a provider token with the smallest possible request to the -/// provider's own endpoint. The caller has already checked that `base_url` -/// is https (or loopback http). -pub async fn check_provider_token(http: &reqwest::Client, base_url: &str, token: &str, model: &str) -> Option { - let url = format!("{}/v1/messages", base_url.trim_end_matches('/')); - let body = serde_json::json!({"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]}); - match http.post(&url).bearer_auth(token).header("anthropic-version", "2023-06-01").json(&body).send().await { - Ok(res) => { - let status = res.status().as_u16(); - log::info!("[Work] Provider token check at {base_url}: status {status}"); - token_verdict(status) - } - Err(err) => { - log::warn!("[Work] Provider token check at {base_url} failed (network): {err}"); - None - } - } -} - -/// What a token check's HTTP status says about the token. 401/403: rejected. -/// Success, or an error the provider only returns once it has accepted the -/// credentials (a malformed request, a rate limit): valid. Anything else — -/// 404 from a wrong base URL, a redirect, a 5xx — never reached the -/// credential check, so it proves nothing either way. -fn token_verdict(status: u16) -> Option { - match status { - 401 | 403 => Some(false), - 200..=299 | 400 | 422 | 429 => Some(true), - _ => None, - } -} - /// Register a paired phone's pubkey with an admin endpoint /// (`POST {pubkey}` with a Bearer token; 200 already registered, 201 /// registered). The token never goes over plaintext except to loopback or an /// onion service. pub async fn register_pubkey(http: &NostrHttp, endpoint: &RegisterEndpoint, pubkey_hex: &str) -> Result<&'static str, String> { - // The same https-or-loopback-http rule as provider base URLs, plus - // http to an onion service. - if !protocol::common::is_valid_provider_base_url(&endpoint.url) && !is_onion_http(&endpoint.url) { + // https, or http to loopback or an onion service: this is Nostr + // traffic, which never crosses a network in cleartext (provider base + // URLs may also use the LAN; this endpoint may not). + if !protocol::common::is_https_or_loopback_url(&endpoint.url) && !is_onion_http(&endpoint.url) { return Err("insecure endpoint (the admin token requires https)".into()); } if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) { @@ -149,19 +118,6 @@ pub async fn register_pubkey(http: &NostrHttp, endpoint: &RegisterEndpoint, pubk mod tests { use super::*; - #[test] - fn only_a_status_past_the_credential_check_is_a_token_verdict() { - assert_eq!(token_verdict(200), Some(true)); - assert_eq!(token_verdict(400), Some(true)); - assert_eq!(token_verdict(429), Some(true)); - assert_eq!(token_verdict(401), Some(false)); - assert_eq!(token_verdict(403), Some(false)); - assert_eq!(token_verdict(404), None, "a wrong base URL says nothing about the token"); - assert_eq!(token_verdict(301), None); - assert_eq!(token_verdict(500), None); - assert_eq!(token_verdict(503), None); - } - #[tokio::test] async fn registration_refuses_plaintext_and_bad_keys_before_any_request() { let http = NostrHttp::new(None).unwrap(); diff --git a/crates/client-core/src/bridge_api.rs b/crates/client-core/src/bridge_api.rs index 143c705e..203c256b 100644 --- a/crates/client-core/src/bridge_api.rs +++ b/crates/client-core/src/bridge_api.rs @@ -492,6 +492,7 @@ mod tests { "type": "set-provider-profile", "profileId": "p", "profile": { + "agent": "claude-code", "label": "L", "baseUrl": "http://api.example.com", "authToken": "tok", diff --git a/crates/client-core/src/stores/machines.rs b/crates/client-core/src/stores/machines.rs index f5cb0a10..cfdb1ac6 100644 --- a/crates/client-core/src/stores/machines.rs +++ b/crates/client-core/src/stores/machines.rs @@ -1847,9 +1847,11 @@ mod tests { machine: "pk".into(), profiles: vec![ProviderProfileInfo { id: "prof1".into(), + agent: "claude-code".into(), label: "Anthropic".into(), base_url: "https://api.example".into(), models: vec![], + models_from_provider: false, default_model: None, has_token: true, }], diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 17fef077..41740b30 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -93,10 +93,14 @@ pub struct UniffiProviderModelWrite { /// UniFFI-crossable mirror of [`protocol::commands::ProviderProfileWrite`]. #[derive(Debug, Clone, uniffi::Record)] pub struct UniffiProviderProfileWrite { + pub agent: String, pub label: String, pub base_url: String, pub auth_token: UniffiTristate, pub models: Vec, + /// The bridge reads the models from the provider instead (`models` is + /// then ignored). + pub models_from_provider: bool, pub default_model: Option, } @@ -519,10 +523,12 @@ impl TryFrom for Intent { machine, profile_id, profile: profile.map(|p| ProviderProfileWrite { + agent: p.agent, label: p.label, base_url: p.base_url, auth_token: p.auth_token.into(), models: p.models.into_iter().map(|m| ProviderModel { id: m.id, label: m.label }).collect(), + models_from_provider: p.models_from_provider, default_model: p.default_model, }), }, @@ -744,10 +750,12 @@ mod tests { machine: "m".into(), profile_id: "kimi-k3".into(), profile: Some(UniffiProviderProfileWrite { + agent: "claude-code".into(), label: "Kimi K3".into(), base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: UniffiTristate::Set { value: "sk-1".into() }, models: vec![UniffiProviderModelWrite { id: "kimi-k3".into(), label: Some("Kimi K3".into()) }], + models_from_provider: true, default_model: Some("kimi-k3".into()), }), }; @@ -758,10 +766,12 @@ mod tests { machine: "m".into(), profile_id: "kimi-k3".into(), profile: Some(ProviderProfileWrite { + agent: "claude-code".into(), label: "Kimi K3".into(), base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: Tristate::Set("sk-1".into()), models: vec![ProviderModel { id: "kimi-k3".into(), label: Some("Kimi K3".into()) }], + models_from_provider: true, default_model: Some("kimi-k3".into()), }), } diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index b65a20ff..3ca5c05c 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -339,9 +339,14 @@ fn to_uniffi_model_entries(models: &[protocol::events::ModelEntry]) -> Vec, + /// The models are the provider's own list. + pub models_from_provider: bool, pub default_model: Option, pub has_token: bool, } @@ -378,6 +383,8 @@ pub struct UniffiAgent { pub supports_models: bool, pub supports_usage: bool, pub supports_providers: bool, + /// The agent's provider profiles add models to its own list. + pub supports_provider_models: bool, pub supports_gsd: bool, pub supports_interrupt: bool, pub supports_commands: bool, @@ -427,6 +434,7 @@ fn to_uniffi_agent(a: &AgentDescriptor) -> UniffiAgent { supports_models: a.supports.models, supports_usage: a.supports.usage, supports_providers: a.supports.providers, + supports_provider_models: a.supports.provider_models, supports_gsd: a.supports.gsd, supports_interrupt: a.supports.interrupt, supports_commands: a.supports.commands, @@ -747,6 +755,7 @@ pub fn build_uniffi_machines_view(v: &MachinesView) -> UniffiMachinesView { .iter() .map(|p| UniffiProviderProfileInfo { id: p.id.clone(), + agent: p.agent.clone(), label: p.label.clone(), base_url: p.base_url.clone(), models: p @@ -754,6 +763,7 @@ pub fn build_uniffi_machines_view(v: &MachinesView) -> UniffiMachinesView { .iter() .map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: Some(p.label.clone()) }) .collect(), + models_from_provider: p.models_from_provider, default_model: p.default_model.clone(), has_token: p.has_token, }) diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 1f92e7c2..2ffe748c 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -51,8 +51,9 @@ { "type": "pair-request", "npub": "npub1x", "pubkeyHex": "aa", "label": "phone", "token": "t" }, { "type": "pair-request", "npub": "npub1x", "pubkeyHex": "aa", "label": "phone", "token": "t", "sessionKey": { "pubkeyHex": "bb", "bridgePubkeyHex": "cc", "expiresAt": 1800000000 } }, { "type": "set-provider-profile", "profileId": "p", "profile": null }, - { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "https://api.example.com/v1", "authToken": "tok", "models": [{ "id": "m1", "label": "M1" }], "defaultModel": "m1" } }, - { "type": "set-provider-profile", "profileId": "p", "profile": { "label": "L", "baseUrl": "http://localhost:11434/v1", "models": [{ "id": "m1" }] } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "agent": "claude-code", "label": "L", "baseUrl": "https://api.example.com/v1", "authToken": "tok", "models": [{ "id": "m1", "label": "M1" }], "defaultModel": "m1" } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "agent": "claude-code", "label": "L", "baseUrl": "http://localhost:11434/v1", "models": [{ "id": "m1" }] } }, + { "type": "set-provider-profile", "profileId": "p", "profile": { "agent": "claude-code", "label": "L", "baseUrl": "https://openrouter.ai/api", "authToken": "tok", "models": [], "modelsFromProvider": true, "defaultModel": "m1" } }, { "type": "provider-profiles-request" }, { "type": "session-key", "sessionKey": { "pubkeyHex": "bb", "bridgePubkeyHex": "cc", "expiresAt": 1800000000 } } ], @@ -88,7 +89,7 @@ { "type": "sessions", "machine": "m", "sessions": [], "agents": [], "protocolVersion": 11, "rev": 1790000000000 }, { "type": "sessions", "machine": "m", "sessions": [], "agents": [], "protocolVersion": 11, "direct": { "endpoints": ["wss://192.168.1.20:7447", "ws://abc.onion:7448"], "certSha256": "00ff" } }, { "type": "sessions", "machine": "m", "sessions": [], "agents": [], "protocolVersion": 11, "direct": { "endpoints": ["ws://abc.onion:7448"] } }, - { "type": "sessions", "machine": "m", "host": "service", "sessions": [{ "id": "s", "agent": "opencode", "slug": "sl", "cwd": "/w", "lastActivity": "t", "lineCount": 1, "title": null, "project": "p", "mode": "build", "effort": "high", "state": "running", "seqHigh": 10 }], "agents": [{ "id": "claude-code", "displayName": "Claude Code", "modes": [{ "id": "default", "label": "Default" }, { "id": "plan", "label": "Plan", "description": "Read-only planning" }], "efforts": [{ "id": "high", "label": "High" }], "defaultMode": "default", "defaultEffort": "high", "supports": { "models": true, "usage": true, "providers": true, "gsd": true, "interrupt": true, "commands": true, "plugins": true }, "credentials": [{ "id": "anthropic_api_key", "label": "Anthropic API key", "present": true, "fromEnv": true }] }, { "id": "opencode", "displayName": "OpenCode" }], "credentials": [{ "id": "github_pat", "label": "GitHub token", "present": false }], "protocolVersion": 11, "capabilities": ["sync/1", "files", "chunked"], "folders": ["a", "b"], "roots": ["/w"], "removedSessions": ["old"], "machineOffline": true }, + { "type": "sessions", "machine": "m", "host": "service", "sessions": [{ "id": "s", "agent": "opencode", "slug": "sl", "cwd": "/w", "lastActivity": "t", "lineCount": 1, "title": null, "project": "p", "mode": "build", "effort": "high", "state": "running", "seqHigh": 10 }], "agents": [{ "id": "claude-code", "displayName": "Claude Code", "modes": [{ "id": "default", "label": "Default" }, { "id": "plan", "label": "Plan", "description": "Read-only planning" }], "efforts": [{ "id": "high", "label": "High" }], "defaultMode": "default", "defaultEffort": "high", "supports": { "models": true, "usage": true, "providers": true, "gsd": true, "interrupt": true, "commands": true, "plugins": true }, "credentials": [{ "id": "anthropic_api_key", "label": "Anthropic API key", "present": true, "fromEnv": true }] }, { "id": "opencode", "displayName": "OpenCode", "supports": { "models": true, "providerModels": true } }], "credentials": [{ "id": "github_pat", "label": "GitHub token", "present": false }], "protocolVersion": 11, "capabilities": ["sync/1", "files", "chunked"], "folders": ["a", "b"], "roots": ["/w"], "removedSessions": ["old"], "machineOffline": true }, { "type": "output", "sessionId": "s", "seq": 1, "entries": [{ "timestamp": "2026-08-05T00:00:00.000Z", "entryType": "text", "role": "user", "text": "hello" }] }, { "type": "output", "sessionId": "s", "seq": 19, "entries": [{ "timestamp": "t", "entryType": "tool_call", "callId": "c3", "toolName": "Read", "kind": "read", "title": "a.rs" }, { "timestamp": "t", "entryType": "tool_result", "callId": "c3", "text": "fn main() {}" }, { "timestamp": "t", "entryType": "turn_complete" }] }, { "type": "output", "sessionId": "s", "seq": 2, "entries": [{ "timestamp": "t", "entryType": "text", "role": "agent", "text": "Running the tests." }] }, @@ -147,7 +148,7 @@ { "type": "credentials-ack", "machine": "m", "success": false, "credentials": [], "error": "not writable" }, { "type": "pair-ack", "machine": "m", "ok": true }, { "type": "pair-ack", "machine": "m", "ok": false, "reason": "bad-token", "relays": ["wss://r"], "host": "cli" }, - { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }] }, + { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }], "modelsFromProvider": true, "hasToken": true }] }, { "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false } ], "rejected": [ diff --git a/crates/protocol/src/codec.rs b/crates/protocol/src/codec.rs index e1840939..26c58c7f 100644 --- a/crates/protocol/src/codec.rs +++ b/crates/protocol/src/codec.rs @@ -143,10 +143,12 @@ mod tests { version: VersionFields::default(), profile_id: "p".into(), profile: Some(ProviderProfileWrite { + agent: "claude-code".into(), label: "L".into(), base_url: "http://api.example.com".into(), auth_token: Tristate::Set("tok".into()), models: vec![], + models_from_provider: false, default_model: None, }), }); @@ -159,10 +161,12 @@ mod tests { version: VersionFields::default(), profile_id: "p".into(), profile: Some(ProviderProfileWrite { + agent: "claude-code".into(), label: "L".into(), base_url: "http://localhost:11434/v1".into(), auth_token: Tristate::Keep, models: vec![], + models_from_provider: false, default_model: None, }), }); diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index 5bca2155..5d1d2bd8 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -363,13 +363,23 @@ pub struct SessionKeyMsg { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProviderProfileWrite { + /// The agent the profile is for (one whose catalog entry `supports` + /// `providers` or `providerModels`). + pub agent: String, pub label: String, /// CDX-071: https, or http ONLY on loopback — validated on egress /// ([`super::codec::encode_phone_to_bridge`]). pub base_url: String, #[serde(default, skip_serializing_if = "Tristate::is_keep")] pub auth_token: Tristate, + /// Ignored when `models_from_provider` is set. pub models: Vec, + /// Ask the provider for its models instead: the bridge reads its + /// `/v1/models` with the profile's token on every save, and stores + /// what it lists (or refuses the save when it lists nothing). + #[serde(default, skip_serializing_if = "super::common::is_false")] + pub models_from_provider: bool, + /// With `models_from_provider`, kept only when the provider lists it. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, } @@ -521,7 +531,7 @@ mod tests { let m = rt(&json!({"type":"set-provider-profile","profileId":"p","profile":null})); assert!(matches!(m, PhoneToBridge::SetProviderProfile(SetProviderProfileMsg { profile: None, .. }))); let m = rt(&json!({"type":"set-provider-profile","profileId":"p","profile":{ - "label":"L","baseUrl":"https://api.x","authToken":"tok","models":[{"id":"m1"}] + "agent":"claude-code","label":"L","baseUrl":"https://api.x","authToken":"tok","models":[{"id":"m1"}] }})); match m { PhoneToBridge::SetProviderProfile(s) => { diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index f9b3f99c..f7fe68d6 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -12,7 +12,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -fn is_false(b: &bool) -> bool { +pub(crate) fn is_false(b: &bool) -> bool { !*b } @@ -39,9 +39,15 @@ pub struct AgentSupports { /// `usage-request` returns subscription usage for this agent's sessions. #[serde(default)] pub usage: bool, - /// Sessions may be bound to a custom provider profile (`providerId`). + /// Sessions may be bound to one of this agent's provider profiles + /// (`providerId`), which then serves the whole session. #[serde(default)] pub providers: bool, + /// This agent's provider profiles add their models to its own model + /// list, beside every provider it already has; a session picks one of + /// them as it would any other model. + #[serde(default)] + pub provider_models: bool, /// `gsd-request` returns GSD workflow state for this agent's sessions. #[serde(default)] pub gsd: bool, @@ -432,50 +438,106 @@ pub struct RemoteSessionInfo { // --- provider base URL rule (CDX-071) --- /// The message BOTH ends show when a base URL is rejected. -pub const PROVIDER_BASE_URL_ERROR: &str = - "Base URL must be https:// (http:// is allowed only for localhost, 127.0.0.1 or [::1])"; +pub const PROVIDER_BASE_URL_ERROR: &str = "Base URL must be https:// (http:// is allowed only for this machine — localhost, \ + 127.0.0.1, [::1] — or an address on your own network, such as 192.168.1.10)"; + +/// Is `raw` https, or http to this machine (`localhost` / `127.0.0.1` / +/// `[::1]`, matched exactly)? The rule for an endpoint whose traffic must +/// not cross any network in cleartext. +pub fn is_https_or_loopback_url(raw: &str) -> bool { + match plain_http_host(raw) { + PlainHttp::NotHttp(https) => https, + PlainHttp::Host(host) => matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"), + PlainHttp::Refused => false, + } +} -/// Is `raw` an acceptable custom-provider base URL? https anywhere, or http -/// ONLY on loopback (`localhost` / `127.0.0.1` / `[::1]`, matched exactly). A -/// local model server has no cert and its traffic never leaves the machine; -/// anything else is a network hop carrying a bearer token. +/// Is `raw` an acceptable custom-provider base URL? https anywhere; http to +/// this machine, where a local model server has no cert and the traffic +/// never leaves it; or http to an IP address of the user's own network — a +/// gateway or model server at home or on the office LAN, which rarely has a +/// cert either. Its token then crosses that network in cleartext, which the +/// user chose by pointing a profile there; anything beyond it (a public +/// address, or a name DNS could point anywhere) needs https. /// -/// The http branch refuses whatever the WHATWG URL parser (what actually -/// dials, e.g. reqwest) could read differently from this minimal split: -/// userinfo (`http://evil.com@localhost`), a backslash (a path separator -/// there, so `http://evil.com\@localhost` dials evil.com), and whitespace or -/// control characters (silently removed there). Refusing is the safe side of -/// any remaining disagreement: a host this split does not recognise as -/// loopback is rejected, never guessed at. +/// Private means the IPv4 ranges set aside for local networks (10/8, +/// 172.16/12, 192.168/16), carrier-grade NAT space (100.64/10, which VPN +/// overlays such as Tailscale number their machines in), and IPv6 unique +/// local addresses (fc00::/7) — written as addresses, never as names. pub fn is_valid_provider_base_url(raw: &str) -> bool { - // Minimal scheme+host split — no url crate (protocol package stays dep-light). + if is_https_or_loopback_url(raw) { + return true; + } + match plain_http_host(raw) { + PlainHttp::Host(host) => is_private_network_address(&host), + _ => false, + } +} + +enum PlainHttp { + /// Not `http://`: whether it is a usable `https://` URL instead. + NotHttp(bool), + /// An `http://` URL's host, lower-case (an IPv6 literal without its + /// brackets). + Host(String), + /// An `http://` URL this split cannot read exactly as a WHATWG parser + /// would. + Refused, +} + +/// A minimal scheme+host split — no url crate (protocol package stays +/// dep-light). +/// +/// It refuses whatever the WHATWG URL parser (what actually dials, e.g. +/// reqwest) could read differently: userinfo (`http://evil.com@localhost`), +/// a backslash (a path separator there, so `http://evil.com\@localhost` +/// dials evil.com), and whitespace or control characters (silently removed +/// there). Refusing is the safe side of any remaining disagreement: a host +/// this split does not recognise is rejected, never guessed at. +fn plain_http_host(raw: &str) -> PlainHttp { let rest = match raw.split_once("://") { Some((scheme, rest)) => match scheme.to_ascii_lowercase().as_str() { - "https" => return !rest.is_empty(), + "https" => return PlainHttp::NotHttp(!rest.is_empty()), "http" => rest, - _ => return false, + _ => return PlainHttp::NotHttp(false), }, - None => return false, + None => return PlainHttp::NotHttp(false), }; if rest.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\\') { - return false; + return PlainHttp::Refused; } let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); if authority.contains('@') { - return false; + return PlainHttp::Refused; } let host = if let Some(stripped) = authority.strip_prefix('[') { - // IPv6 literal: [::1] or [::1]:port — nothing else after the bracket. + // IPv6 literal: [addr] or [addr]:port — nothing else after the bracket. match stripped.split_once(']') { - Some((h, after)) => { - return h == "::1" && (after.is_empty() || after.starts_with(':')) - } - None => return false, + Some((h, after)) if after.is_empty() || after.starts_with(':') => h, + _ => return PlainHttp::Refused, } } else { authority.rsplit_once(':').map_or(authority, |(h, _)| h) }; - matches!(host.to_ascii_lowercase().as_str(), "localhost" | "127.0.0.1") + PlainHttp::Host(host.to_ascii_lowercase()) +} + +/// Is `host` an IP address on a private network (see +/// [`is_valid_provider_base_url`])? Only the canonical spelling counts: +/// std's parser refuses the shorthands and leading zeros a WHATWG parser +/// would read as another address (`010.0.0.1` is 8.0.0.1 there). +fn is_private_network_address(host: &str) -> bool { + if let Ok(v4) = host.parse::() { + let [a, b, ..] = v4.octets(); + return a == 10 + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 100 && (64..=127).contains(&b)); + } + if let Ok(v6) = host.parse::() { + return v6.segments()[0] & 0xfe00 == 0xfc00; + } + false } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] @@ -492,9 +554,14 @@ pub struct ProviderModel { #[serde(rename_all = "camelCase")] pub struct ProviderProfileInfo { pub id: String, + /// The agent the profile is for: the endpoint speaks that agent's API. + pub agent: String, pub label: String, pub base_url: String, pub models: Vec, + /// The models are the provider's own list, read when it was last saved. + #[serde(default, skip_serializing_if = "is_false")] + pub models_from_provider: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, pub has_token: bool, @@ -969,6 +1036,43 @@ mod tests { assert!(!is_valid_provider_base_url("https://")); } + #[test] + fn a_provider_may_be_on_the_users_own_network() { + for ok in [ + "http://192.168.1.2:3458", + "http://10.0.0.7/v1", + "http://172.16.0.1", + "http://172.31.255.254:80", + "http://100.101.102.103:8080", + "http://[fd12:3456::1]:3000/v1", + "http://[FC00::1]", + ] { + assert!(is_valid_provider_base_url(ok), "{ok}"); + assert!(!is_https_or_loopback_url(ok), "{ok}"); + } + for refused in [ + "http://172.32.0.1", + "http://192.169.0.1", + "http://100.128.0.1", + "http://8.8.8.8", + "http://[2001:db8::1]", + "http://[fe80::1]", + // Spellings a WHATWG parser reads as another address. + "http://010.0.0.1", + "http://192.168.1", + "http://0xc0.168.1.1", + // A name could point anywhere. + "http://router.local", + "http://192.168.1.2.nip.io", + "http://192.168.1.2@evil.com", + ] { + assert!(!is_valid_provider_base_url(refused), "{refused}"); + } + assert!(is_https_or_loopback_url("https://relay.example")); + assert!(is_https_or_loopback_url("http://localhost:8080")); + assert!(!is_https_or_loopback_url("http://[::2]")); + } + #[test] fn provider_base_url_rule_refuses_what_a_whatwg_parser_reads_differently() { assert!(is_valid_provider_base_url("http://LOCALHOST:8080")); diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 30ade0e4..80ba44e5 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -545,7 +545,7 @@ mod tests { rt(&json!({"type":"credentials-ack","machine":"m","agent":"claude-code","success":true, "credentials":[{"id":"anthropic_api_key","label":"Anthropic API key","present":true,"valid":true}]})); rt(&json!({"type":"pair-ack","machine":"m","ok":false,"reason":"bad-token","relays":["wss://r"],"host":"cli"})); - rt(&json!({"type":"provider-profiles","machine":"m","profiles":[{"id":"p","label":"L","baseUrl":"https://x","models":[{"id":"m"}],"hasToken":true}]})); + rt(&json!({"type":"provider-profiles","machine":"m","profiles":[{"id":"p","agent":"claude-code","label":"L","baseUrl":"https://x","models":[{"id":"m"}],"hasToken":true}]})); rt(&json!({"type":"provider-profile-ack","machine":"m","profileId":"p","success":true,"tokenValid":false})); } diff --git a/docs/AGENT-CANDIDATES.md b/docs/AGENT-CANDIDATES.md index 802dbb5c..10d31d66 100644 --- a/docs/AGENT-CANDIDATES.md +++ b/docs/AGENT-CANDIDATES.md @@ -97,6 +97,17 @@ catalog entry that runs Pi with our extensions (permission gate, question and plan-review tools, a short system prompt). Decide after the Pi driver exists, when it costs one extension package rather than a project. +### The default agent: OpenCode + +Decided 2026-10-08. A default has to work with any OpenAI-compatible +provider or gateway, take its models from the endpoint's `/v1/models`, and +offer no tool that only works on its vendor's API. OpenCode meets all +three: its web search is offered only on its own provider (checked on +1.18.32), its web fetch is local, and a provider profile adds the +endpoint's models to its list beside OpenCode Zen's free ones — read from +the endpoint, never typed by hand. Codex is out (its client speaks only the +Responses API); a Pi-based entry stays an option once the Pi driver exists. + ## Installing agents on demand Today every driver the host is told to load (`CODEDECK_AGENT_HOST_DRIVERS`, diff --git a/docs/DEEPSEEK.md b/docs/DEEPSEEK.md index c27be37a..ef98b6d0 100644 --- a/docs/DEEPSEEK.md +++ b/docs/DEEPSEEK.md @@ -112,7 +112,8 @@ side — a session's provider binding applies to its whole process, so the two do not mix. Both go through the same rules as the other agents: the base URL must be -`https://` (plain `http://` only to localhost, 127.0.0.1 or `[::1]`), a token +`https://` (plain `http://` only to this machine, or to an IP address of your +own network such as a gateway at home), a token is required, and the harness's whole `DEEPSEEK_*` namespace is dropped from the environment of a bound session — an operator's native key must not be billed for a session bound somewhere else. `DSH_HOME` survives: where the diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index c1b998d6..642a7f38 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -61,7 +61,7 @@ event-id set alongside the cursor so the replay is a no-op. Nothing in the wire names a particular coding agent. The heartbeat carries `agents: AgentDescriptor[]` — per agent its `id`, `displayName`, `modes[]`, `efforts[]`, `defaultMode`, `defaultEffort`, `supports {models, usage, -providers, gsd, interrupt, commands, plugins, mcp, tasks}` and `credentials[]` status. Phones build every +providers, providerModels, gsd, interrupt, commands, plugins, mcp, tasks}` and `credentials[]` status. Phones build every picker from it and offer a feature only when the session's agent `supports` it. Mode, effort and model values are opaque strings the bridge validates against the catalog. @@ -210,14 +210,40 @@ this message, inbound; status only ever goes out.** ### Custom provider profiles -Phone-managed, bridge-stored profiles that point a session at an -Anthropic-compatible backend. Only agents with `supports.providers` accept one. +Phone-managed, bridge-stored profiles of another endpoint: a provider's own +API, or a gateway in front of several. Each is for one `agent` — the +endpoint must speak the API that agent uses, and one that speaks one +agent's need not speak another's. What a profile does is the agent's +catalog entry: + +- `supports.providers`: a session can be bound to one of the agent's + profiles (`create-session.providerId`), which then serves the whole + session; +- `supports.providerModels`: the agent's profiles add their models to its + own model list, beside every provider it already has; a session picks one + as any other model, and is never bound. + +Messages: - `set-provider-profile {profileId, profile | null}`: upsert or delete (`null` - deletes). `profile.authToken` is tri-state: absent = keep, `null` = clear, - string = set. The base URL must be https (http only on loopback) — the - bridge never stores an insecure profile, and refuses to start a session on - one written before that rule. + deletes). `profile.agent` names an agent with either flag. `profile.authToken` + is tri-state: absent = keep, `null` = clear, string = set. The base URL must + be https, or http to this machine (`localhost`, `127.0.0.1`, `[::1]`) or to + an IP address of the user's own network (10/8, 172.16/12, 192.168/16, + 100.64/10, fc00::/7 — addresses, never names) — the bridge never stores + another, and refuses to start a session on one written before that rule. +- `profile.modelsFromProvider: true`: the phone lists no models; the agent + host reads them from the endpoint's `/v1/models` (`/models` when the base + URL already ends in `/v1`), signing in as the profile's agent does, with + the profile's token, on every save, and the bridge stores at most 200. No + redirect is followed, and the save is refused when the list cannot be read + or is empty. A `defaultModel` the list does not name is dropped. The stored + profile reports the flag back, so a later save (with the token kept) reads + the list again. +- The token is checked by the profile's agent, with the smallest request on + the API it speaks (`tokenValid` in the ack). +- A profile stored before profiles named their agent has an empty `agent`: + no agent uses it until a save names one. - `provider-profiles-request` → `provider-profiles {profiles[]}` to the asking phone; after every change the bridge broadcasts the new list to all phones. - `provider-profile-ack {profileId, success, tokenValid?, error?}`; @@ -488,6 +514,9 @@ The bridge's ids are `b1, b2, …`; the host's are `h1, h2, …`. | `session-mcp {sessionId}` | `session-mcp {servers, toggles, projectWide}` | | `session-mcp-toggle {sessionId, name, enabled}` | `session-mcp {…}` once done, or `error` | | `check-credential {agent, credential, value}` | `credential-checked {valid?}` | +| `check-provider {agent, provider, model}` | `credential-checked {valid?}`: a provider profile's token, checked on the API `agent` speaks | +| `list-provider-models {agent, baseUrl, authToken}` | `provider-models {models}` (never empty), read the way `agent` signs in; or `error` with the reason there is none | +| `set-providers {agent, providers}` | `ack` once an agent with `supports.providerModels` offers these profiles' models (all of its profiles, sent after `initialize` and on every change), or `error` | `AgentInfo` is the catalog entry minus credential status (the bridge adds that), plus `credentials[].envVar` and `unavailableReason`. @@ -534,6 +563,11 @@ restart. `requestPlanApproval()` when the user must decide. 2. Translate the agent's own events into typed `OutputEntry` values in the driver — nothing agent-specific may reach the bridge. + For provider profiles, say in `supports` how the agent uses one + (`providers`: bound to a session through `StartSession.provider`; + `providerModels`: added to its models through `setProviders()`), and + implement `listProviderModels()` and `checkProvider()` with the API the + agent speaks to an endpoint (`src/sdk/providerApi.ts`). 3. Export a `DriverModule` (`src/sdk/module.ts`) from the folder's `module.ts`: the agent's id, how its driver is built from the environment, and its runtime (what the machine already has, and how to diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts index 6dc1c8fc..8f6ea6f5 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts @@ -471,12 +471,12 @@ describe('Claude options and setup', () => { it('a provider binding that must not be used refuses the session before anything starts', () => { const facade = new ScriptedFacade(); - expect(() => start({ provider: { id: 'p', baseUrl: 'http://remote.example', authToken: 't', models: [] } }, {}, facade)).toThrow(/insecure base URL/); + expect(() => start({ provider: { id: 'p', label: 'p', baseUrl: 'http://remote.example', authToken: 't', models: [] } }, {}, facade)).toThrow(/insecure base URL/); expect(facade.sessions).toHaveLength(0); }); it('a provider-bound session never falls back to an Anthropic model', () => { - const { facade } = start({ provider: { id: 'kimi', baseUrl: 'https://api.moonshot.ai/anthropic', authToken: 't', models: [{ id: 'kimi-k3' }] } }); + const { facade } = start({ provider: { id: 'kimi', label: 'kimi', baseUrl: 'https://api.moonshot.ai/anthropic', authToken: 't', models: [{ id: 'kimi-k3' }] } }); expect(facade.last.opts).toMatchObject({ providerId: 'kimi', fallbackModel: null }); expect(facade.last.opts.env?.ANTHROPIC_AUTH_TOKEN).toBe('t'); }); @@ -686,7 +686,7 @@ describe('Claude model discovery', () => { expect(chosen.facade.last.opts.model).toBe('claude-sonnet-5'); expect(chosen.ctx.events.some((e) => e.type === 'info')).toBe(false); - const bound = start({ provider: { id: 'p', baseUrl: 'https://x', authToken: 't', models: [] } }); + const bound = start({ provider: { id: 'p', label: 'p', baseUrl: 'https://x', authToken: 't', models: [] } }); await bound.ctx.waitFor((e) => e.type === 'ready'); expect(bound.facade.last.opts.model).toBeUndefined(); }); @@ -717,7 +717,7 @@ describe('Claude model checks', () => { }); it('holds a provider-bound session to its profile', () => { - const provider = { id: 'kimi', baseUrl: 'https://x', authToken: 't', models: [{ id: 'kimi-k3' }] }; + const provider = { id: 'kimi', label: 'kimi', baseUrl: 'https://x', authToken: 't', models: [{ id: 'kimi-k3' }] }; expect(unsupportedModelReason('kimi-k3', [], provider)).toBeUndefined(); expect(unsupportedModelReason('claude-opus-5-5', known, provider)).toMatch(/profile 'kimi' does not offer/); }); diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts index 52ed62ca..5d9bcf68 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts @@ -16,6 +16,7 @@ const KIMI_TOKEN = 'sk-kimi-TESTSECRET-000'; function kimi(over: Partial = {}): ProviderBinding { return { id: 'kimi', + label: 'Kimi', baseUrl: 'https://api.moonshot.ai/anthropic', authToken: KIMI_TOKEN, models: [{ id: 'kimi-k3', label: 'Kimi K3' }, { id: 'kimi-k3-turbo' }], diff --git a/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts b/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts index 2121bf19..1b97e85f 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/facadeModels.test.ts @@ -79,9 +79,10 @@ import { fetchGatewayModels, isProviderBoundSession, modelSupports1mContext, - parseGatewayModels, + toGatewayModels, type SdkSessionOptions, } from '../facade'; +import { parseProviderModels } from '../../../sdk/providerModels'; function baseOpts(over: Partial = {}): SdkSessionOptions { return { @@ -443,8 +444,8 @@ describe('fetchGatewayModels', () => { global.fetch = (async (url: string, init?: RequestInit) => { calls.push([url, init]); return { - ok: true, - json: async () => ({ + status: 200, + text: async () => JSON.stringify({ // Real shape from a claude-code-router instance: some entries // carry display_name, some don't. data: [ @@ -460,7 +461,7 @@ describe('fetchGatewayModels', () => { expect(calls).toHaveLength(1); const [url, init] = calls[0]!; expect(url).toBe('http://router.example:3458/v1/models'); // trailing slash on the base URL stripped - expect((init?.headers as Record).Authorization).toBe('Bearer tok-123'); + expect((init?.headers as Record).authorization).toBe('Bearer tok-123'); expect(models).toEqual([ { id: 'Claude Code API/claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'Claude Code API' }, { id: 'Z.ai (Global) - Coding Plan/glm-5.2', label: 'glm-5.2', provider: 'Z.ai (Global) - Coding Plan' }, @@ -472,10 +473,10 @@ describe('fetchGatewayModels', () => { process.env.ANTHROPIC_API_KEY = 'k'; let userAgent: string | undefined; global.fetch = (async (_url: string, init?: RequestInit) => { - userAgent = (init?.headers as Record)['User-Agent']; + userAgent = (init?.headers as Record)['user-agent']; return { - ok: true, - json: async () => ({ + status: 200, + text: async () => JSON.stringify({ data: [ { id: `anthropic/claude-ccr-h${hex('Gateway Test/glm-9-flash')}[1m]`, display_name: 'Gateway Test/GLM-9-Flash (1M context)', max_input_tokens: 1_310_720 }, { id: `anthropic/claude-ccr-h${hex('Gateway Test/claude-opus-9')}`, display_name: 'Gateway Test/Claude Opus 9', max_input_tokens: 200_000 }, @@ -501,8 +502,8 @@ describe('fetchGatewayModels', () => { process.env.ANTHROPIC_API_KEY = 'api-key'; let authHeader: string | undefined; global.fetch = (async (_url: string, init?: RequestInit) => { - authHeader = (init?.headers as Record).Authorization; - return { ok: true, json: async () => ({ data: [] }) }; + authHeader = (init?.headers as Record).authorization; + return { status: 200, text: async () => JSON.stringify({ data: [] }) }; }) as unknown as typeof fetch; await fetchGatewayModels(); @@ -512,7 +513,7 @@ describe('fetchGatewayModels', () => { it('returns [] on a non-ok response rather than throwing', async () => { process.env.ANTHROPIC_BASE_URL = 'http://router.example'; process.env.ANTHROPIC_API_KEY = 'k'; - global.fetch = (async () => ({ ok: false, status: 401, statusText: 'Unauthorized' })) as unknown as typeof fetch; + global.fetch = (async () => ({ status: 401, text: async () => '' })) as unknown as typeof fetch; expect(await fetchGatewayModels()).toEqual([]); }); @@ -528,7 +529,7 @@ describe('fetchGatewayModels', () => { it('returns [] when the response has no "data" array', async () => { process.env.ANTHROPIC_BASE_URL = 'http://router.example'; process.env.ANTHROPIC_API_KEY = 'k'; - global.fetch = (async () => ({ ok: true, json: async () => ({ unexpected: 'shape' }) })) as unknown as typeof fetch; + global.fetch = (async () => ({ status: 200, text: async () => JSON.stringify({ unexpected: 'shape' }) })) as unknown as typeof fetch; expect(await fetchGatewayModels()).toEqual([]); }); @@ -536,10 +537,12 @@ describe('fetchGatewayModels', () => { const hex = (text: string): string => Buffer.from(text, 'utf8').toString('hex'); -describe('parseGatewayModels', () => { +describe('toGatewayModels', () => { + const parse = (body: unknown) => toGatewayModels(parseProviderModels(body)); + it('decodes claude-code-router ids, drops the provider from labels, and reads each context window', () => { // The Anthropic-shaped list claude-code-router sends a Claude Code client. - const parsed = parseGatewayModels({ + const parsed = parse({ data: [ { id: `anthropic/claude-ccr-h${hex('Z.ai (Global) - Coding Plan/glm-5.3-flash')}[1m]`, @@ -557,31 +560,26 @@ describe('parseGatewayModels', () => { { id: `anthropic/claude-ccr-h${hex('Golem/local-model')}`, display_name: 'Golem/local-model', max_input_tokens: 0 }, ], }); - expect(parsed?.models).toEqual([ + expect(parsed.models).toEqual([ { id: 'Z.ai (Global) - Coding Plan/glm-5.3-flash', label: 'GLM-5.3-Flash (1M context)', provider: 'Z.ai (Global) - Coding Plan' }, { id: 'Z.ai (Global) - Coding Plan/glm-4.7-flash', label: 'GLM-4.7-Flash', provider: 'Z.ai (Global) - Coding Plan' }, { id: 'Golem/local-model', label: 'local-model', provider: 'Golem' }, ]); - expect([...parsed!.oneMillion]).toEqual([ + expect([...parsed.oneMillion]).toEqual([ ['Z.ai (Global) - Coding Plan/glm-5.3-flash', true], ['Z.ai (Global) - Coding Plan/glm-4.7-flash', false], ]); }); it('takes a plain list as it is, saying nothing about context', () => { - const parsed = parseGatewayModels({ data: [{ id: 'claude-sonnet-5', display_name: 'Claude Sonnet 5' }, { id: 7 }] }); - expect(parsed?.models).toEqual([{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }]); - expect(parsed?.oneMillion.size).toBe(0); + const parsed = parse({ data: [{ id: 'claude-sonnet-5', display_name: 'Claude Sonnet 5' }, { id: 7 }] }); + expect(parsed.models).toEqual([{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }]); + expect(parsed.oneMillion.size).toBe(0); }); it('lists a model once when it comes both with and without the [1m] marker', () => { - const parsed = parseGatewayModels({ data: [{ id: 'claude-opus-5[1m]' }, { id: 'claude-opus-5' }] }); - expect(parsed?.models).toEqual([{ id: 'claude-opus-5', label: 'claude-opus-5' }]); - expect(parsed?.oneMillion.get('claude-opus-5')).toBe(true); - }); - - it('is null without a data list', () => { - expect(parseGatewayModels({ unexpected: 'shape' })).toBeNull(); - expect(parseGatewayModels(null)).toBeNull(); + const parsed = parse({ data: [{ id: 'claude-opus-5[1m]' }, { id: 'claude-opus-5' }] }); + expect(parsed.models).toEqual([{ id: 'claude-opus-5', label: 'claude-opus-5' }]); + expect(parsed.oneMillion.get('claude-opus-5')).toBe(true); }); }); diff --git a/packages/agent-host/src/drivers/claude/driver.ts b/packages/agent-host/src/drivers/claude/driver.ts index 0c4d057e..f4e63d8a 100644 --- a/packages/agent-host/src/drivers/claude/driver.ts +++ b/packages/agent-host/src/drivers/claude/driver.ts @@ -15,6 +15,9 @@ import * as path from 'node:path'; import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; import { mcpStatus } from '../../sdk/mcp'; import type { HttpPost } from '../../sdk/net'; +import type { ProviderHttp } from '../../sdk/net'; +import { ANTHROPIC_API, checkToken, listModels } from '../../sdk/providerApi'; +import type { EndpointModel } from '../../sdk/providerModels'; import { isBenignPlanDirWrite } from './policy'; import { PERMISSION_ALLOW, PERMISSION_ALLOW_ALWAYS, PERMISSION_DENY, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; import { newTranslateContext } from '../../sdk/transcript'; @@ -150,6 +153,8 @@ export interface ClaudeDriverDeps { installClaude?: () => Promise; /** Outbound HTTP for the API-key check. */ httpPost?: HttpPost; + /** Outbound HTTP to provider profiles' endpoints. */ + providerHttp?: ProviderHttp; /** Spawn a throwaway session for the model list when no live session can * answer it — and once at start, so the list is ready before any session * exists. */ @@ -846,6 +851,17 @@ export class ClaudeDriver implements Driver { } } + /** A profile's endpoint is Claude Code's Anthropic API: a session on it + * posts to its Messages API. */ + async checkProvider(provider: ProviderBinding, model: string): Promise { + return this.options.providerHttp ? checkToken(ANTHROPIC_API, provider, model, this.options.providerHttp.post) : undefined; + } + + async listProviderModels(baseUrl: string, token: string): Promise { + if (!this.options.providerHttp) throw new Error('this host reads no provider endpoints'); + return listModels(ANTHROPIC_API, baseUrl, token, this.options.providerHttp.get); + } + deleteConversation(conversationId: string, cwd: string): Promise { return this.options.facade.deleteSession(conversationId, cwd); } diff --git a/packages/agent-host/src/drivers/claude/facade.ts b/packages/agent-host/src/drivers/claude/facade.ts index f31b6edd..9b871887 100644 --- a/packages/agent-host/src/drivers/claude/facade.ts +++ b/packages/agent-host/src/drivers/claude/facade.ts @@ -19,6 +19,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { findInDirs, findOnPath, isFile } from '../../sdk/executable'; +import type { HttpGet } from '../../sdk/net'; +import { fetchProviderModels, type EndpointModel } from '../../sdk/providerModels'; import type { CanUseTool, Options, @@ -285,7 +287,7 @@ export const DISCOVERY_CACHE_MS = 10 * 60_000; * such a client in Anthropic's own `/v1/models` shape, which says how large * each model's context window is (when the router knows), and anyone else * with a plain list that does not. What it says about the 1M window is kept - * for `modelSupports1mContext`; see `parseGatewayModels` for both shapes. + * for `modelSupports1mContext`; see `toGatewayModels`. */ export async function fetchGatewayModels(): Promise { const baseUrl = process.env.ANTHROPIC_BASE_URL; @@ -294,42 +296,33 @@ export async function fetchGatewayModels(): Promise { console.error('[SdkFacade] fetchGatewayModels: ANTHROPIC_BASE_URL or an auth token is not set'); return []; } - - try { - const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/v1/models`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'anthropic-version': '2023-06-01', - 'User-Agent': GATEWAY_USER_AGENT, - }, - // Generous relative to SUPPORTED_MODELS_TIMEOUT_MS: a cold gateway - // enumerating several upstream providers is slower than one live CLI - // answering a control request. - signal: AbortSignal.timeout(5_000), - }); - if (!response.ok) { - console.error(`[SdkFacade] fetchGatewayModels: gateway returned ${response.status} ${response.statusText}`); - return []; - } - - const parsed = parseGatewayModels(await response.json()); - if (!parsed) { - console.error('[SdkFacade] fetchGatewayModels: response has no "data" array'); - return []; - } - for (const [id, oneMillion] of parsed.oneMillion) gatewayOneMillion.set(contextKey(id), oneMillion); - return parsed.models; - } catch (err) { - console.error('[SdkFacade] fetchGatewayModels: request failed:', err); - return []; - } + const models = await fetchProviderModels(baseUrl, { + token, + headers: { 'anthropic-version': '2023-06-01', 'user-agent': GATEWAY_USER_AGENT }, + httpGet: gatewayGet, + log: (line) => console.error(line), + tag: '[SdkFacade] fetchGatewayModels:', + }); + if (!models) return []; + const parsed = toGatewayModels(models); + for (const [id, oneMillion] of parsed.oneMillion) gatewayOneMillion.set(contextKey(id), oneMillion); + return parsed.models; } /** Any `claude-code/…` agent gets claude-code-router's Anthropic-shaped * model list, the one that carries context-window sizes. */ const GATEWAY_USER_AGENT = 'claude-code/1.0 (codedeck)'; +/** The gateway's list, read with a shorter timeout than the host's other + * calls: a phone is waiting on it. Generous next to + * SUPPORTED_MODELS_TIMEOUT_MS all the same — a cold gateway enumerating + * several upstream providers is slower than one live CLI answering a + * control request. */ +const gatewayGet: HttpGet = async (url, headers) => { + const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(5_000) }); + return { status: res.status, text: await res.text() }; +}; + /** A model id compared without its `[1m]` marker or case. */ const contextKey = (id: string): string => id.replace(/\[1m\]$/i, '').toLowerCase(); @@ -337,79 +330,35 @@ const contextKey = (id: string): string => id.replace(/\[1m\]$/i, '').toLowerCas * itself last said (by `contextKey`). Filled by `fetchGatewayModels`. */ const gatewayOneMillion = new Map(); -/** claude-code-router's id for a model as it lists it to Claude Code: - * `anthropic/claude-ccr-h`, the hex being the router's own - * `/` id in UTF-8. */ -const CCR_ENCODED_ID = /^(?:anthropic\/)?claude-ccr-h((?:[0-9a-f]{2})+)$/i; - /** A window at least this large is the 1M tier (a provider's "1M" is not * always exactly a million: 1 048 576, 1 050 000 and 1 310 720 all occur). */ const ONE_MILLION_CONTEXT = 1_000_000; -interface GatewayModelEntry { - id: string; - display_name?: unknown; - max_input_tokens?: unknown; - capabilities?: { context_window?: { supports_1m_context?: unknown; max_input_tokens?: unknown } }; -} - -/** Whether a gateway entry says it has the 1M window: its explicit flag, - * else a `[1m]` id, else its input-token limit. Undefined when it says - * nothing (a plain list, or a size the gateway does not know, sent as 0). */ -function entryOneMillion(m: GatewayModelEntry): boolean | undefined { - const window = m.capabilities?.context_window; - if (typeof window?.supports_1m_context === 'boolean') return window.supports_1m_context; - if (/\[1m\]$/i.test(m.id)) return true; - const max = [m.max_input_tokens, window?.max_input_tokens].find((n): n is number => typeof n === 'number' && n > 0); - return max === undefined ? undefined : max >= ONE_MILLION_CONTEXT; -} - /** - * A gateway's `/v1/models` body as the phone's model list, plus what each - * model's entry says about the 1M window. Null when there is no `data` list. + * A gateway's models (`sdk/providerModels`) as the phone's model list, plus + * what each says about the 1M window: its own word when it gives one (a flag, + * or a `[1m]` id), else its input-token limit; nothing for a plain list. * - * Two shapes arrive. claude-code-router's plain list has the router's ids - * (`Z.ai (Global) - Coding Plan/glm-5.3-flash`) and nothing on context. Its - * Anthropic-shaped list, sent to a Claude Code client, encodes each id - * (`CCR_ENCODED_ID`), marks a 1M model's id with `[1m]` and its - * `display_name` with "(1M context)", and gives the window's size. The listed - * id is always the router's own, decoded and without the marker: it is what - * a session sends, and what the phone keeps as a machine's default. The label - * drops the provider prefix, which the phone shows on its own. + * claude-code-router answers a Claude Code client with an Anthropic-shaped + * list whose ids it encodes and whose 1M models it marks; the shared parser + * decodes both, so the id here is always the router's own — what a session + * sends, and what the phone keeps as a machine's default. A model without a + * name of its own is labelled by the part after its provider, which the + * phone shows on its own. */ -export function parseGatewayModels( - body: unknown, -): { models: SdkModelDescriptor[]; oneMillion: Map } | null { - const list = (body as { data?: unknown } | null)?.data; - if (!Array.isArray(list)) return null; - const models: SdkModelDescriptor[] = []; +export function toGatewayModels(models: EndpointModel[]): { models: SdkModelDescriptor[]; oneMillion: Map } { const oneMillion = new Map(); - for (const m of list) { - if (typeof (m as { id?: unknown })?.id !== 'string') continue; - const entry = m as GatewayModelEntry; - const bare = entry.id.replace(/\[1m\]$/i, ''); - const hex = CCR_ENCODED_ID.exec(bare)?.[1]; - const id = (hex && Buffer.from(hex, 'hex').toString('utf8')) || bare; - if (models.some((known) => known.id === id)) continue; - // A router-prefixed id ("Claude Code API/claude-sonnet-5") names its - // channel before the first "/"; the model after it may hold a "/" of - // its own. The same model can come through several channels, so the - // channel is kept as the provider, and a label without display_name - // is the model part rather than the raw id. - const slash = id.indexOf('/'); - const provider = slash > 0 ? id.slice(0, slash) : undefined; - const named = typeof entry.display_name === 'string' && provider && entry.display_name.startsWith(`${provider}/`) - ? entry.display_name.slice(provider.length + 1) - : entry.display_name; - models.push({ - id, - label: (typeof named === 'string' && named) || (provider ? id.slice(slash + 1) : id), - ...(provider ? { provider } : {}), - }); - const said = entryOneMillion(entry); - if (said !== undefined) oneMillion.set(id, said); - } - return { models, oneMillion }; + const list = models.map((m) => { + const said = + m.oneMillionContext ?? (m.contextWindow !== undefined ? m.contextWindow >= ONE_MILLION_CONTEXT : undefined); + if (said !== undefined) oneMillion.set(m.id, said); + return { + id: m.id, + label: m.label ?? (m.provider ? m.id.slice(m.provider.length + 1) : m.id), + ...(m.provider ? { provider: m.provider } : {}), + }; + }); + return { models: list, oneMillion }; } /** diff --git a/packages/agent-host/src/drivers/claude/module.ts b/packages/agent-host/src/drivers/claude/module.ts index 319f109b..27ea3f7e 100644 --- a/packages/agent-host/src/drivers/claude/module.ts +++ b/packages/agent-host/src/drivers/claude/module.ts @@ -6,7 +6,7 @@ */ import { installBinary } from '../../install/agentInstall'; import type { DriverEnv, DriverModule } from '../../sdk/module'; -import { httpPost } from '../../sdk/net'; +import { httpPost, providerHttp } from '../../sdk/net'; import { ClaudeDriver } from './driver'; import { RealSdkFacade, resolveClaudeExecutable } from './facade'; import { bundledClaudeExecutable, claudeBinary } from './install'; @@ -36,6 +36,7 @@ export const claudeModule: DriverModule = { // so the agent is listed right away and the first session waits for it. ...(!test && !claudePath ? { installClaude: () => install(ctx) } : {}), httpPost, + providerHttp, discoverModels: !test, managePlugins: !test, manageMcp: !test, diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts similarity index 57% rename from packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts rename to packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts index d82ab03e..7e7d7b96 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCatalog.test.ts @@ -1,5 +1,5 @@ /** - * A gateway the harness is pointed at: reading the models it serves, and + * The endpoint the harness is pointed at: reading the models it serves, and * writing them into the harness's own profile as its catalog — beside the * block the MCP list owns, since both live in that one file. */ @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { DeepSeekMcp } from '../mcp'; -import { fetchGatewayCatalog, gatewayModelsUrl, parseModels, renderCatalogLayer, syncGatewayCatalog } from '../gateway'; +import { fetchEndpointCatalog, renderCatalogLayer, syncEndpointCatalog } from '../catalog'; function profile(initial = '# Your patch layer for this dsh profile.\n[]\n'): string { const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-gateway-')); @@ -26,49 +26,28 @@ const answering = (body: unknown, status = 200, expectUrl?: string, expectKey?: return { status, text: typeof body === 'string' ? body : JSON.stringify(body) }; }); -describe('the model list a gateway serves', () => { - it('is read from the root the harness itself posts to', async () => { - expect(gatewayModelsUrl('http://gateway.example:3458')).toBe('http://gateway.example:3458/v1/models'); - expect(gatewayModelsUrl('http://gateway.example:3458/')).toBe('http://gateway.example:3458/v1/models'); - // The harness appends `/v1` unless the path already ends in it, and this - // mirrors that rule, so one setting configures both. - expect(gatewayModelsUrl('https://gateway.example/v1')).toBe('https://gateway.example/v1/models'); - }); - - it('reads the shapes gateways answer with, and keeps what says something', () => { - expect(parseModels({ data: [{ id: 'kimi-k2' }, { id: 'glm-4.6', context_length: 200_000 }] })).toEqual([ - { id: 'kimi-k2' }, - { id: 'glm-4.6', contextWindow: 200_000 }, - ]); - expect(parseModels(['a', 'b'])).toEqual([{ id: 'a' }, { id: 'b' }]); - expect(parseModels({ models: [{ id: 'x', name: 'X' }] })).toEqual([{ id: 'x', name: 'X' }]); - // Duplicates, blanks and anything that is not a model are dropped. - expect(parseModels({ data: [{ id: 'a' }, { id: 'a' }, { id: ' ' }, { no: 'id' }, 7] })).toEqual([{ id: 'a' }]); - expect(parseModels({ error: 'nope' })).toEqual([]); - expect(parseModels('not json at all')).toEqual([]); - }); - +describe('the model list the endpoint serves', () => { it('is asked with the key, and answered with the endpoint as the harness reads it', async () => { const httpGet = answering({ data: [{ id: 'kimi-k2' }] }, 200, 'http://gw.example/v1/models', 'sk-1'); - const catalog = await fetchGatewayCatalog('http://gw.example/', 'sk-1', httpGet, () => {}); + const catalog = await fetchEndpointCatalog('http://gw.example/', 'sk-1', httpGet, () => {}); expect(catalog).toEqual({ baseUrl: 'http://gw.example', models: [{ id: 'kimi-k2' }], defaultModel: 'kimi-k2' }); }); - it('answers nothing — and says so — when the gateway refuses, breaks or lists nothing', async () => { + it('answers nothing — and says so — when the endpoint refuses, breaks or lists nothing', async () => { const logs: string[] = []; const log = (line: string): void => { logs.push(line); }; - expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering({}, 401), log)).toBeUndefined(); - expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering('', 200), log)).toBeUndefined(); - expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering({ data: [] }, 200), log)).toBeUndefined(); + expect(await fetchEndpointCatalog('http://gw.example', 'sk', answering({}, 401), log)).toBeUndefined(); + expect(await fetchEndpointCatalog('http://gw.example', 'sk', answering('', 200), log)).toBeUndefined(); + expect(await fetchEndpointCatalog('http://gw.example', 'sk', answering({ data: [] }, 200), log)).toBeUndefined(); expect( - await fetchGatewayCatalog('http://gw.example', 'sk', async () => { + await fetchEndpointCatalog('http://gw.example', 'sk', async () => { throw new Error('ECONNREFUSED'); }, log), ).toBeUndefined(); - expect(logs.some((line) => /answered 401/.test(line))).toBe(true); - expect(logs.some((line) => /listed no models/.test(line))).toBe(true); + expect(logs.some((line) => /refused the token \(HTTP 401\)/.test(line))).toBe(true); + expect(logs.some((line) => /lists no models/.test(line))).toBe(true); expect(logs.some((line) => /ECONNREFUSED/.test(line))).toBe(true); }); }); @@ -100,7 +79,7 @@ describe('the catalog the harness reads', () => { it('is written beside the MCP block without disturbing it, and taken back out again', async () => { const dir = profile(); await new DeepSeekMcp({ profileDir: dir, log: () => {} }).act('add', [{ name: 'demo', setup: { type: 'stdio', command: '/usr/bin/demo' } }], []); - await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet: answering({ data: [{ id: 'kimi-k2' }] }) }, 'http://gw.example', 'sk-1'); + await syncEndpointCatalog({ profileDir: dir, log: () => {}, httpGet: answering({ data: [{ id: 'kimi-k2' }] }) }, 'http://gw.example', 'sk-1'); const both = layerOf(dir); expect(both).toMatch(/CodeDeck\+ MCP servers/); expect(both).toMatch(/serverName: demo/); @@ -112,16 +91,16 @@ describe('the catalog the harness reads', () => { { name: 'demo', transport: 'stdio', target: '/usr/bin/demo', enabled: true }, ]); - // No gateway any more: its block goes, the MCP one stays. - await syncGatewayCatalog({ profileDir: dir, log: () => {} }, undefined, undefined); + // No endpoint any more: its block goes, the MCP one stays. + await syncEndpointCatalog({ profileDir: dir, log: () => {} }, undefined, undefined); const after = layerOf(dir); expect(after).not.toMatch(/gateway catalog/); expect(after).toMatch(/serverName: demo/); }); - it('stays as it was when the gateway cannot be read', async () => { + it('stays as it was when the endpoint cannot be read', async () => { const dir = profile(); - const wrote = await syncGatewayCatalog({ profileDir: dir, log: () => {} }, 'http://gw.example', 'sk-1'); + const wrote = await syncEndpointCatalog({ profileDir: dir, log: () => {} }, 'http://gw.example', 'sk-1'); expect(wrote).toBe(false); expect(layerOf(dir)).toBe('# Your patch layer for this dsh profile.\n[]\n'); }); @@ -129,7 +108,7 @@ describe('the catalog the harness reads', () => { it('is written only when it changed', async () => { const dir = profile(); const httpGet = answering({ data: [{ id: 'kimi-k2' }] }); - expect(await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(true); - expect(await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(false); + expect(await syncEndpointCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(true); + expect(await syncEndpointCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(false); }); }); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts index 867ea428..e65fac4d 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts @@ -155,7 +155,7 @@ describe('DeepSeekSession startup', () => { const ready = withDriver(); const ctx = await started(ready, { model: 'kimi-k2', - provider: { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-x', models: [] }, + provider: { id: 'p1', label: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-x', models: [] }, }); expect(ready.harness.setOptions.some((option) => option.configId === 'model')).toBe(true); expect(ctx.entries().some((entry) => entry.entryType === 'error' && /kimi-k2/.test(entry.text))).toBe(true); @@ -192,7 +192,7 @@ describe('DeepSeekSession environment', () => { it('hands the session its provider binding, replacing the harness namespace', async () => { const ready = withDriver({ env: { PATH: '/bin', DEEPSEEK_API_KEY: 'native-key', KEEP: 'yes' } }); await started(ready, { - provider: { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [] }, + provider: { id: 'p1', label: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [] }, }); expect(ready.spawns[0]?.env.DEEPSEEK_BASE_URL).toBe('https://gateway.example/v1'); expect(ready.spawns[0]?.env.DEEPSEEK_API_KEY).toBe('sk-gateway'); @@ -210,7 +210,7 @@ describe('DeepSeekSession environment', () => { const ctx = recordingContext(); expect(() => ready.driver.startSession( - start({ provider: { id: 'p1', baseUrl: 'http://gateway.example/v1', authToken: 'sk', models: [] } }), + start({ provider: { id: 'p1', label: 'p1', baseUrl: 'http://gateway.example/v1', authToken: 'sk', models: [] } }), ctx, ), ).toThrow(/insecure base URL/); @@ -220,7 +220,7 @@ describe('DeepSeekSession environment', () => { const ready = withDriver(); await started(ready, { sessionId: 'b1' }); await started(ready, { sessionId: 'b2' }); - const gateway = { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk', models: [] }; + const gateway = { id: 'p1', label: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk', models: [] }; await started(ready, { sessionId: 'b3', provider: gateway }); expect(ready.spawns).toHaveLength(2); }); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts index e496d3b2..efcbe4e7 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts @@ -23,6 +23,7 @@ const BASE: Record = { const provider = (overrides: Partial = {}): ProviderBinding => ({ id: 'gateway', + label: 'Gateway', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [], diff --git a/packages/agent-host/src/drivers/deepseek/catalog.ts b/packages/agent-host/src/drivers/deepseek/catalog.ts new file mode 100644 index 00000000..2523ff64 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/catalog.ts @@ -0,0 +1,127 @@ +/** + * The model catalog of the endpoint the harness is pointed at. + * + * An operator who sets `DEEPSEEK_BASE_URL` is saying "run sessions on this + * endpoint" — another provider's API, or a gateway in front of several. The + * harness takes that endpoint from the environment (its DeepSeek route reads + * exactly that variable), but its *model catalog* is its own, fixed list of + * DeepSeek model names — so an endpoint with other models would be sent a + * name it does not know, and the phone would offer models it cannot serve. + * What the endpoint serves is the one thing only it can say, at its + * `/v1/models` (`sdk/providerModels`), on the same root the harness itself + * appends `/v1` to and then posts `/messages` to. + * + * So this module asks the endpoint for that list and writes it into the + * harness's own profile, as the deployment's catalog for the native route: + * a patch row over the `llm-deepseek` entry, which is how the harness + * documents its catalog as replaceable. From the next harness start, the + * phone offers exactly what the endpoint serves and a session can select any + * of it. No endpoint configured (or one that does not answer) means no row + * at all: the harness's own catalog stands, and nothing is guessed. + */ +import * as path from 'node:path'; +import { dump } from 'js-yaml'; +import type { HttpGet } from '../../sdk/net'; +import { fetchProviderModels, type EndpointModel } from '../../sdk/providerModels'; +import { ProfileLayer, type LayerBlock } from './profileLayer'; + +/** Our block in the profile's patch layer. Its marker text is what finds the + * block again in a profile written by an earlier version, so it stays as + * it was first written. */ +const BLOCK: LayerBlock = { + begin: "# --- CodeDeck+ gateway catalog: written from the gateway's own model list; everything outside this block is yours ---", + end: '# --- end CodeDeck+ gateway catalog ---', +}; + +/** The entry that mounts the native DeepSeek adapter; its config carries the + * endpoint and the catalog. */ +const PROVIDER_ROW = 'llm-deepseek'; +/** The entries naming the provider and model a session starts on. They have + * to move with the catalog: the harness always offers the model it is on, + * so a default the endpoint does not serve would appear as one nobody can + * run. `acp` is the profile's own selection (what a new session starts + * with); `agent-default-model` is the agent module's. */ +const SESSION_MODEL_ROW = 'acp'; +const DEFAULT_MODEL_ROW = 'agent-default-model'; +/** The provider the native route is registered under. */ +const NATIVE_PROVIDER = 'deepseek-official'; + +export interface EndpointCatalog { + /** The endpoint as the harness should read it. */ + baseUrl: string; + models: EndpointModel[]; + /** The model sessions start on: the endpoint's own first entry, in the + * order it lists them. */ + defaultModel: string; +} + +/** + * The catalog the endpoint serves, or `undefined` when it could not be read + * (see `fetchProviderModels`). + */ +export async function fetchEndpointCatalog( + baseUrl: string, + key: string | undefined, + httpGet: HttpGet, + log: (message: string) => void, +): Promise { + const models = await fetchProviderModels(baseUrl, { ...(key ? { token: key } : {}), httpGet, log, tag: '[deepseek]' }); + if (!models) { + log(`[deepseek] leaving the harness's own catalog in place`); + return undefined; + } + return { baseUrl: baseUrl.replace(/\/+$/, ''), models, defaultModel: models[0]!.id }; +} + +/** + * The patch rows for an endpoint's catalog: the catalog itself, and the + * model a session starts on. + * + * Never the endpoint. The profile is shared by every harness process, and + * the route takes a `baseURL` in its config over `DEEPSEEK_BASE_URL` in the + * environment — so an endpoint written here would also be where a session + * bound to a provider profile sent that profile's token, whatever its own + * environment named. The operator's process finds the endpoint in the + * environment it already has. + */ +export function renderCatalogLayer(catalog: EndpointCatalog): string { + const models = catalog.models.map((model) => ({ + id: model.id, + ...(model.label !== undefined ? { name: model.label } : {}), + ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), + })); + const selection = { provider: NATIVE_PROVIDER, model: catalog.defaultModel }; + const rows = [ + { id: PROVIDER_ROW, config: { models } }, + { id: SESSION_MODEL_ROW, config: selection }, + { id: DEFAULT_MODEL_ROW, config: selection }, + ]; + return dump(rows, { lineWidth: -1, noRefs: true, quotingType: "'" }).trimEnd(); +} + +export interface CatalogSyncOptions { + /** The profile directory (`$DSH_HOME/profiles/acp`). */ + profileDir: string; + log: (message: string) => void; + httpGet?: HttpGet; +} + +/** + * Write the endpoint's catalog into the harness profile (or take our row + * out when no endpoint is configured), and answer whether the layer changed. + */ +export async function syncEndpointCatalog( + options: CatalogSyncOptions, + baseUrl: string | undefined, + key: string | undefined, +): Promise { + const layer = new ProfileLayer(path.join(options.profileDir, 'cordis.patch.yml'), options.log); + if (!baseUrl || !options.httpGet) return layer.set(BLOCK, undefined); + const catalog = await fetchEndpointCatalog(baseUrl, key, options.httpGet, options.log); + if (!catalog) return false; + const changed = await layer.set(BLOCK, renderCatalogLayer(catalog)); + if (changed) { + options.log(`[deepseek] ${catalog.models.length} models from ${catalog.baseUrl} now make up the harness's catalog`); + } + return changed; +} diff --git a/packages/agent-host/src/drivers/deepseek/driver.ts b/packages/agent-host/src/drivers/deepseek/driver.ts index 2f99ebba..50fb6641 100644 --- a/packages/agent-host/src/drivers/deepseek/driver.ts +++ b/packages/agent-host/src/drivers/deepseek/driver.ts @@ -24,7 +24,9 @@ */ import type { RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption, SessionNotification } from '@agentclientprotocol/sdk'; import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; -import type { HttpGet } from '../../sdk/net'; +import type { HttpGet, ProviderHttp } from '../../sdk/net'; +import { ANTHROPIC_API, checkToken, listModels } from '../../sdk/providerApi'; +import type { EndpointModel } from '../../sdk/providerModels'; import { mcpStatus as mcpStatusOf } from '../../sdk/mcp'; import { parseSlashCommand } from '../../sdk/commands'; import { PERMISSION_ALLOW, PERMISSION_DENY, now, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; @@ -33,6 +35,7 @@ import type { McpStatus, ModelEntry, OutputEntry, + ProviderBinding, SessionMcpServer, SessionOption, SlashCommand, @@ -46,7 +49,8 @@ import { HARNESS_PLUGIN, installHarnessPlugin, QUESTION_MARKER } from './plugin' import { ASK_USER_TOOL, installProfileTools } from './profileTools'; import { parseQuestionLine, planReviewOf, toAnswerItems, toQuestionSpecs, type PlanReview, type PushedQuestionLine } from './questions'; import { deleteDshConversation } from './conversations'; -import { gatewayModelsUrl, syncGatewayCatalog } from './gateway'; +import { providerModelsUrl } from '../../sdk/providerModels'; +import { syncEndpointCatalog } from './catalog'; import { DSH_LABEL } from './install'; import { DeepSeekMcp } from './mcp'; import { DeepSeekPlugins, runDshPlugin, type DshRun } from './plugins'; @@ -788,6 +792,8 @@ export interface DeepSeekDriverOptions { baseEnv?: NodeJS.ProcessEnv; /** HTTPS client for the credential check. */ httpGet?: HttpGet; + /** Outbound HTTP to provider profiles' endpoints. */ + providerHttp?: ProviderHttp; /** Spawn seam for the plugin commands, for tests. */ spawnFn?: SpawnFn; log: (message: string) => void; @@ -851,7 +857,7 @@ export class DeepSeekDriver implements Driver { * start): the endpoint's catalog, and CodeDeck's own rows in its patch * layer. */ private async prepareProfile(): Promise { - await this.syncGateway(); + await this.syncCatalog(); await this.ownProfile(); } @@ -879,14 +885,14 @@ export class DeepSeekDriver implements Driver { /** * Point the harness at the operator's endpoint, with the models that - * endpoint serves (gateway.ts): without this a session on a gateway would - * be sent a DeepSeek model name the gateway does not know, and the phone + * endpoint serves (catalog.ts): without this a session on another + * endpoint would be sent a DeepSeek model name it does not know, and the phone * would offer models that are not there. */ - private async syncGateway(): Promise { + private async syncCatalog(): Promise { const env = this.options.baseEnv ?? process.env; try { - await syncGatewayCatalog( + await syncEndpointCatalog( { profileDir: dshProfileDir(this.options.home), log: this.options.log, ...(this.options.httpGet ? { httpGet: this.options.httpGet } : {}) }, env[DEEPSEEK_BASE_URL_ENV]?.trim() || undefined, env[DEEPSEEK_API_KEY_ENV]?.trim() || undefined, @@ -894,7 +900,7 @@ export class DeepSeekDriver implements Driver { } catch (error) { // The catalog is a convenience, never a reason to refuse to run: the // harness's own models still work. - this.options.log(`[deepseek] could not write the gateway's catalog: ${error instanceof Error ? error.message : String(error)}`); + this.options.log(`[deepseek] could not write the endpoint's catalog: ${error instanceof Error ? error.message : String(error)}`); } } @@ -988,9 +994,9 @@ export class DeepSeekDriver implements Driver { } /** - * Check the key against the endpoint it is for: the operator's gateway when - * one is configured (its `/models` is the one call every gateway has in - * common — the same one this driver reads the catalog from), else the + * Check the key against the endpoint it is for: the operator's endpoint + * when one is configured (its `/models` is the one call every endpoint has + * in common — the same one this driver reads the catalog from), else the * DeepSeek API. A refusal is a refusal; anything else says nothing. */ async checkCredential(credential: string, value: string): Promise { @@ -998,7 +1004,7 @@ export class DeepSeekDriver implements Driver { const baseEnv = this.options.baseEnv ?? process.env; const base = baseEnv[DEEPSEEK_BASE_URL_ENV]?.trim(); try { - const res = await this.options.httpGet(base ? gatewayModelsUrl(base) : 'https://api.deepseek.com/models', { + const res = await this.options.httpGet(base ? providerModelsUrl(base) : 'https://api.deepseek.com/models', { authorization: `Bearer ${value}`, }); return res.status !== 401 && res.status !== 403; @@ -1007,6 +1013,17 @@ export class DeepSeekDriver implements Driver { } } + /** The harness reaches a profile's endpoint through its DeepSeek route, + * which speaks Anthropic's Messages API under the endpoint's `/v1`. */ + async checkProvider(provider: ProviderBinding, model: string): Promise { + return this.options.providerHttp ? checkToken(ANTHROPIC_API, provider, model, this.options.providerHttp.post) : undefined; + } + + async listProviderModels(baseUrl: string, token: string): Promise { + if (!this.options.providerHttp) throw new Error('this host reads no provider endpoints'); + return listModels(ANTHROPIC_API, baseUrl, token, this.options.providerHttp.get); + } + deleteConversation(conversationId: string): Promise { return deleteDshConversation(this.options.home, conversationId); } diff --git a/packages/agent-host/src/drivers/deepseek/gateway.ts b/packages/agent-host/src/drivers/deepseek/gateway.ts deleted file mode 100644 index 1b6d346a..00000000 --- a/packages/agent-host/src/drivers/deepseek/gateway.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * A gateway the harness is pointed at, end to end. - * - * An operator who sets `DEEPSEEK_BASE_URL` is saying "run sessions on this - * endpoint". The harness takes that endpoint from the environment (its - * DeepSeek route reads exactly that variable), but its *model catalog* is its - * own, fixed list of DeepSeek model names — so a gateway with its own models - * would be sent a name it does not know, and the phone would offer models the - * gateway cannot serve. What the gateway serves is the one thing only the - * gateway can say, and it says it at `/models` (the OpenAI-shaped list every - * gateway exposes), on the same root the harness itself appends `/v1` to and - * then posts `/messages` to. - * - * So this module asks the gateway for that list and writes it into the - * harness's own profile, as the deployment's catalog for the native route: - * a patch row over the `llm-deepseek` entry, which is how the harness - * documents its catalog as replaceable. From the next harness start, the - * phone offers exactly what the gateway serves and a session can select any - * of it. No gateway configured (or one that does not answer) means no row at - * all: the harness's own catalog stands, and nothing is guessed. - */ -import * as path from 'node:path'; -import { dump } from 'js-yaml'; -import type { HttpGet } from '../../sdk/net'; -import { ProfileLayer, type LayerBlock } from './profileLayer'; - -/** Our block in the profile's patch layer. */ -const BLOCK: LayerBlock = { - begin: "# --- CodeDeck+ gateway catalog: written from the gateway's own model list; everything outside this block is yours ---", - end: '# --- end CodeDeck+ gateway catalog ---', -}; - -/** The entry that mounts the native DeepSeek adapter; its config carries the - * endpoint and the catalog. */ -const PROVIDER_ROW = 'llm-deepseek'; -/** The entries naming the provider and model a session starts on. They have - * to move with the catalog: the harness always offers the model it is on, - * so a default the gateway does not serve would appear as one nobody can - * run. `acp` is the profile's own selection (what a new session starts - * with); `agent-default-model` is the agent module's. */ -const SESSION_MODEL_ROW = 'acp'; -const DEFAULT_MODEL_ROW = 'agent-default-model'; -/** The provider the native route is registered under. */ -const NATIVE_PROVIDER = 'deepseek-official'; - -/** How many of a gateway's models are kept: a catalog is for a phone screen. */ -const MAX_MODELS = 200; - -/** One model, as a catalog entry. */ -export interface GatewayModel { - id: string; - name?: string; - contextWindow?: number; -} - -export interface GatewayCatalog { - /** The endpoint as the harness should read it. */ - baseUrl: string; - models: GatewayModel[]; - /** The model sessions start on: the gateway's own first entry, in the - * order it lists them. */ - defaultModel: string; -} - -/** - * The list a gateway serves, or `undefined` when it could not be read (an - * unreachable endpoint, a refusal, an answer that is not a model list). The - * base URL is normalised the way the harness normalises it, so the same - * variable points at the same endpoint for both. - */ -export async function fetchGatewayCatalog( - baseUrl: string, - key: string | undefined, - httpGet: HttpGet, - log: (message: string) => void, -): Promise { - const url = gatewayModelsUrl(baseUrl); - let body: unknown; - try { - const response = await httpGet(url, key ? { authorization: `Bearer ${key}` } : {}); - if (response.status < 200 || response.status >= 300) { - log(`[deepseek] the gateway at ${baseUrl} answered ${response.status} for its model list`); - return undefined; - } - body = readJson(response); - } catch (error) { - log(`[deepseek] could not read the gateway's model list from ${url}: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - const models = parseModels(body); - if (models.length === 0) { - log(`[deepseek] the gateway at ${baseUrl} listed no models; leaving the harness's own catalog in place`); - return undefined; - } - return { baseUrl: baseUrl.replace(/\/+$/, ''), models, defaultModel: models[0]!.id }; -} - -/** The harness's own rule for where a provider's list of models lives: the - * root ends in `/v1` (the endpoint `/messages` is posted to lives beside - * it). Mirrored here so one setting configures both. */ -export function gatewayModelsUrl(baseUrl: string): string { - const base = baseUrl.replace(/\/+$/, ''); - const rooted = new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; - return `${rooted}/models`; -} - -/** The model ids in a gateway's answer: the OpenAI-shaped `data` array, a - * bare array, or a `models` array. Anything else yields nothing. */ -export function parseModels(body: unknown): GatewayModel[] { - const list = Array.isArray(body) - ? body - : typeof body === 'object' && body !== null - ? ((body as { data?: unknown }).data ?? (body as { models?: unknown }).models) - : undefined; - if (!Array.isArray(list)) return []; - const models: GatewayModel[] = []; - const seen = new Set(); - for (const entry of list) { - const id = - typeof entry === 'string' - ? entry - : typeof entry === 'object' && entry !== null - ? (entry as { id?: unknown; name?: unknown }).id - : undefined; - if (typeof id !== 'string' || id.trim() === '' || seen.has(id)) continue; - seen.add(id); - const context = typeof entry === 'object' && entry !== null ? contextWindowOf(entry as Record) : undefined; - const name = typeof entry === 'object' && entry !== null && typeof (entry as { name?: unknown }).name === 'string' ? (entry as { name: string }).name : undefined; - models.push({ id, ...(name && name !== id ? { name } : {}), ...(context !== undefined ? { contextWindow: context } : {}) }); - if (models.length >= MAX_MODELS) break; - } - return models; -} - -/** A model's context size, when the gateway states one (OpenAI-compatible - * gateways spell it several ways). */ -function contextWindowOf(entry: Record): number | undefined { - for (const field of ['context_window', 'context_length', 'contextWindow', 'max_input_tokens']) { - const value = entry[field]; - if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; - } - return undefined; -} - -function readJson(response: Awaited>): unknown { - return JSON.parse(response.text ?? '') as unknown; -} - -/** - * The patch rows for a gateway's catalog: the catalog itself, and the model - * a session starts on. - * - * Never the endpoint. The profile is shared by every harness process, and - * the route takes a `baseURL` in its config over `DEEPSEEK_BASE_URL` in the - * environment — so an endpoint written here would also be where a session - * bound to a provider profile sent that profile's token, whatever its own - * environment named. The operator's process finds the gateway in the - * environment it already has. - */ -export function renderCatalogLayer(catalog: GatewayCatalog): string { - const models = catalog.models.map((model) => ({ - id: model.id, - ...(model.name !== undefined ? { name: model.name } : {}), - ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), - })); - const selection = { provider: NATIVE_PROVIDER, model: catalog.defaultModel }; - const rows = [ - { id: PROVIDER_ROW, config: { models } }, - { id: SESSION_MODEL_ROW, config: selection }, - { id: DEFAULT_MODEL_ROW, config: selection }, - ]; - return dump(rows, { lineWidth: -1, noRefs: true, quotingType: "'" }).trimEnd(); -} - -export interface GatewaySyncOptions { - /** The profile directory (`$DSH_HOME/profiles/acp`). */ - profileDir: string; - log: (message: string) => void; - httpGet?: HttpGet; -} - -/** - * Write the gateway's catalog into the harness profile (or take our row out - * when no gateway is configured), and answer whether the layer changed. - */ -export async function syncGatewayCatalog( - options: GatewaySyncOptions, - baseUrl: string | undefined, - key: string | undefined, -): Promise { - const layer = new ProfileLayer(path.join(options.profileDir, 'cordis.patch.yml'), options.log); - if (!baseUrl || !options.httpGet) return layer.set(BLOCK, undefined); - const catalog = await fetchGatewayCatalog(baseUrl, key, options.httpGet, options.log); - if (!catalog) return false; - const changed = await layer.set(BLOCK, renderCatalogLayer(catalog)); - if (changed) { - options.log(`[deepseek] ${catalog.models.length} models from ${catalog.baseUrl} now make up the harness's catalog`); - } - return changed; -} diff --git a/packages/agent-host/src/drivers/deepseek/module.ts b/packages/agent-host/src/drivers/deepseek/module.ts index ad8458db..39b017bf 100644 --- a/packages/agent-host/src/drivers/deepseek/module.ts +++ b/packages/agent-host/src/drivers/deepseek/module.ts @@ -5,7 +5,7 @@ */ import type { DriverEnv, DriverModule } from '../../sdk/module'; import { isFile } from '../../sdk/executable'; -import { httpGet } from '../../sdk/net'; +import { httpGet, providerHttp } from '../../sdk/net'; import { DeepSeekDriver } from './driver'; import { takeDeepSeekEnv } from './env'; import { installDshTree } from './install'; @@ -43,6 +43,7 @@ export const deepSeekModule: DriverModule = { mcp, baseEnv: ctx.ownEnv, httpGet, + providerHttp, log: ctx.log, }); if (dshPath && !isFile(dshPath)) { diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts new file mode 100644 index 00000000..37a11fa2 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -0,0 +1,138 @@ +/** + * Provider profiles as OpenCode providers: the config the server starts + * with, the password guarding it, and the restart a changed list takes. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { OpenCodeDriver } from '../driver'; +import { providersConfig, serverSetup } from '../providers'; +import type { StartOpenCodeServerOptions } from '../server'; +import type { ProviderBinding } from '../../../sdk/types'; + +const router = (over: Partial = {}): ProviderBinding => ({ + id: 'router', + label: 'Home router', + baseUrl: 'http://192.168.1.2:3458', + authToken: 'tok-secret', + models: [{ id: 'Z.ai/glm-5', label: 'GLM 5' }, { id: 'kimi-k3' }], + defaultModel: 'kimi-k3', + ...over, +}); + +describe('providersConfig', () => { + it("adds each profile as a provider of its own, beside the operator's, with the token left to the environment", () => { + const config = providersConfig([router()], { provider: { mine: { npm: 'x' } }, theme: 'dark' }); + expect(config).toEqual({ + theme: 'dark', + provider: { + mine: { npm: 'x' }, + 'codedeck-router': { + npm: '@ai-sdk/openai-compatible', + name: 'Home router', + options: { baseURL: 'http://192.168.1.2:3458/v1', apiKey: '{env:CODEDECK_PROVIDER_KEY_0}' }, + models: { 'Z.ai/glm-5': { name: 'GLM 5' }, 'kimi-k3': {} }, + }, + }, + }); + expect(JSON.stringify(config)).not.toContain('tok-secret'); + }); + + it('does not restrict the providers OpenCode already has', () => { + const config = providersConfig([router()]); + expect(config).not.toHaveProperty('enabled_providers'); + expect(config).not.toHaveProperty('disabled_providers'); + expect(config).not.toHaveProperty('model'); + }); +}); + +describe('serverSetup', () => { + it('passes the tokens in the environment and guards the server with a password of its own', () => { + const a = serverSetup([router(), router({ id: 'or', authToken: 'tok-2' })], {}); + expect(a.env.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); + expect(a.env.CODEDECK_PROVIDER_KEY_1).toBe('tok-2'); + expect(JSON.parse(a.env.OPENCODE_CONFIG_CONTENT!).provider).toHaveProperty('codedeck-or'); + const password = a.env.OPENCODE_SERVER_PASSWORD!; + expect(password).toMatch(/^[0-9a-f]{48}$/); + expect(a.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); + expect(serverSetup([], {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); + // No profiles: the operator's config stands as it is. + expect(serverSetup([], { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + }); + + it("keeps the operator's own environment config underneath", () => { + const { env } = serverSetup([router()], { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); + expect(Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider)).toEqual(['mine', 'codedeck-router']); + }); +}); + +describe('an OpenCode driver given provider profiles', () => { + function managed() { + const starts: StartOpenCodeServerOptions[] = []; + const closed: number[] = []; + const connects: Array<{ baseUrl: string; headers?: Record }> = []; + const driver = OpenCodeDriver.create({ + autoStart: true, + // Any file stands in for the executable: the start is faked. + binaryPath: process.execPath, + log: () => {}, + startServer: async (options) => { + starts.push(options); + const n = starts.length; + return { url: `http://127.0.0.1:${4100 + n}`, pid: n, exited: new Promise(() => {}), close: async () => void closed.push(n) }; + }, + connect: (config) => { + connects.push(config); + return {} as OpencodeClient; + }, + }); + return { driver, starts, closed, connects }; + } + + it('adds them to the server it starts, and restarts it only when they change', async () => { + const { driver: created, starts, closed, connects } = managed(); + const driver = await created; + expect(driver.info().supports?.providerModels).toBe(true); + expect(driver.info().supports?.providers).toBe(false); + expect(starts).toHaveLength(1); + expect(starts[0]!.env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + expect(connects[0]!.headers?.authorization).toMatch(/^Basic /); + + await driver.setProviders([router()]); + expect(starts).toHaveLength(2); + expect(closed).toEqual([1]); + expect(starts[1]!.env?.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); + + await driver.setProviders([router()]); + expect(starts).toHaveLength(2); + + // One the bridge would not let a session use is left out. + await driver.setProviders([router(), router({ id: 'bad', baseUrl: 'http://8.8.8.8' })]); + expect(starts).toHaveLength(2); + + await driver.setProviders([]); + expect(starts).toHaveLength(3); + expect(starts[2]!.env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + await driver.shutdown(); + expect(closed).toEqual([1, 2, 3]); + }); + + it('refuses them for a server it does not start', async () => { + const driver = await OpenCodeDriver.create({ serverUrl: 'http://127.0.0.1:4096', log: () => {}, connect: () => ({}) as OpencodeClient }); + expect(driver.info().supports?.providerModels).toBe(false); + await expect(driver.setProviders([router()])).rejects.toThrow(/does not start/); + }); + + it('checks and lists a profile as an OpenAI-compatible endpoint', async () => { + const post = vi.fn().mockResolvedValue({ status: 401 }); + const get = vi.fn().mockResolvedValue({ status: 200, text: JSON.stringify({ data: [{ id: 'kimi-k3' }] }) }); + const driver = await OpenCodeDriver.create({ serverUrl: 'http://x', log: () => {}, providerHttp: { get, post }, connect: () => ({}) as OpencodeClient }); + expect(await driver.checkProvider(router(), 'kimi-k3')).toBe(false); + const [url, headers, body] = post.mock.calls[0]!; + expect(url).toBe('http://192.168.1.2:3458/v1/chat/completions'); + expect(headers).toEqual({ 'content-type': 'application/json', authorization: 'Bearer tok-secret' }); + expect(JSON.parse(body)).toMatchObject({ model: 'kimi-k3', max_tokens: 1 }); + // Its models are read the same way: the OpenAI sign-in only. + expect(await driver.listProviderModels('http://192.168.1.2:3458', 'tok-secret')).toEqual([{ id: 'kimi-k3' }]); + expect(get).toHaveBeenCalledWith('http://192.168.1.2:3458/v1/models', { authorization: 'Bearer tok-secret' }); + }); +}); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index acfb5e26..bc8c3a7b 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -14,6 +14,11 @@ * fabricated number would be worse than none. Context usage is reported * only for models whose provider declares a context limit; * - no effort levels; models are per prompt (`provider/model` ids). + * + * Provider profiles are added to the server this driver starts as + * providers of their own (providers.ts): their models join the list beside + * OpenCode Zen and the operator's providers, and a session picks one like + * any other. */ import { createOpencodeClient } from '@opencode-ai/sdk/v2/client'; import type { @@ -37,6 +42,7 @@ import { newTranslateContext } from '../../sdk/transcript'; import type { AgentInfo, ModelEntry, + ProviderBinding, QuestionSpec, SessionOption, SlashCommand, @@ -47,7 +53,17 @@ import type { import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; -import { resolveOpenCodePath, startOpenCodeServer, type OpenCodeServerHandle } from './server'; +import { providersFingerprint, serverSetup } from './providers'; +import { + resolveOpenCodePath, + startOpenCodeServer, + type OpenCodeServerHandle, + type StartOpenCodeServerOptions, +} from './server'; +import type { ProviderHttp } from '../../sdk/net'; +import { isValidProviderBaseUrl } from '../../sdk/provider'; +import { checkToken, listModels, OPENAI_API } from '../../sdk/providerApi'; +import type { EndpointModel } from '../../sdk/providerModels'; export const OPENCODE_AGENT_ID = 'opencode'; @@ -319,15 +335,17 @@ export class OpenCodeSession implements DriverSession { /** Resolves once the OpenCode session exists server-side. */ private readonly ready: Promise<{ client: OpencodeClient; session: Session }>; + private readonly catalog: () => Promise; constructor( params: StartSession, private readonly ctx: SessionContext, clientPromise: Promise, - private readonly catalog: () => Promise = async () => ({ models: [] }), + options: OpenCodeSessionOptions = {}, ) { this.cwd = params.cwd; this.mode = params.mode ?? DEFAULT_MODE; + this.catalog = options.catalog ?? (async () => ({ models: [] })); this.model = splitModelId(params.model ?? undefined); this.ready = this.init(clientPromise, params); // init() reports its own failure as `ended`; nothing else awaits this @@ -927,6 +945,11 @@ export class OpenCodeSession implements DriverSession { } } +export interface OpenCodeSessionOptions { + /** The models a session may choose from, and its default. */ + catalog?: () => Promise; +} + export interface OpenCodeDriverOptions { /** An OpenCode server to connect to. Wins over `autoStart`. */ serverUrl?: string; @@ -941,6 +964,11 @@ export interface OpenCodeDriverOptions { lookupEnv?: NodeJS.ProcessEnv; port?: number; log: (message: string) => void; + /** Outbound HTTP to provider profiles' endpoints. */ + providerHttp?: ProviderHttp; + /** Seams for tests: how the server is started and connected to. */ + startServer?: (options: StartOpenCodeServerOptions) => Promise; + connect?: (config: { baseUrl: string; headers?: Record }) => OpencodeClient; } /** Why OpenCode cannot run, when it is enabled but unusable. */ @@ -951,19 +979,32 @@ export class OpenCodeDriver implements Driver { private clientPromise: Promise | null = null; private server: OpenCodeServerHandle | null = null; private unavailable: string | undefined; + /** The `opencode` the server runs, once found or installed. */ + private bin: string | null = null; /** Auto-start with no `opencode` on the machine: it is installed, then * started, in the background; a failed attempt is retried by the next * session. */ private installs = false; private stopped = false; + /** The provider profiles the server should have, and those the running + * one was started with. */ + private providers: ProviderBinding[] = []; + private served = providersFingerprint([]); + /** Provider changes are applied one at a time, in order. */ + private applying: Promise = Promise.resolve(); readonly plugins: PluginManager = new OpenCodePlugins(() => this.client()); readonly mcp: McpManager = new OpenCodeMcp(() => this.client()); private constructor(private readonly options: OpenCodeDriverOptions) {} - /** The server's client, starting an install that failed before over. */ + /** The driver starts the server, so it decides the server's config. */ + private get manages(): boolean { + return !this.options.serverUrl && Boolean(this.options.autoStart); + } + + /** The server's client, starting it again when the last attempt failed. */ private client(): Promise { - if (!this.clientPromise && this.installs && !this.stopped) this.launchInstalled(); + if (!this.clientPromise && !this.stopped && (this.bin || this.installs)) this.launch(); return this.clientPromise ?? Promise.reject(new Error(this.unavailable ?? NOT_CONFIGURED)); } @@ -973,22 +1014,20 @@ export class OpenCodeDriver implements Driver { const driver = new OpenCodeDriver(options); if (options.serverUrl) { if (options.autoStart) options.log('[opencode] both a server URL and auto-start are configured — using the server URL'); - driver.connect(options.serverUrl); + driver.clientPromise = Promise.resolve(driver.connect({ baseUrl: options.serverUrl })); } else if (options.autoStart) { - const bin = resolveOpenCodePath(options.binaryPath, options.lookupEnv); - if (!bin && options.installOpenCode) { + driver.bin = resolveOpenCodePath(options.binaryPath, options.lookupEnv); + if (!driver.bin && options.installOpenCode) { driver.installs = true; - driver.launchInstalled(); - } else if (!bin) { + driver.launch(); + } else if (!driver.bin) { driver.unavailable = 'OpenCode auto-start is enabled but the `opencode` executable was not found (set CODEDECK_OPENCODE_PATH).'; } else { try { - driver.server = await startOpenCodeServer({ command: bin, ...(options.port !== undefined ? { port: options.port } : {}) }); - options.log(`[opencode] started ${driver.server.url} (pid ${driver.server.pid ?? '?'})`); - driver.connect(driver.server.url); + driver.clientPromise = Promise.resolve(await driver.startServer(driver.bin)); } catch (err) { - driver.unavailable = `The OpenCode server failed to start: ${err instanceof Error ? err.message : String(err)}`; + driver.unavailable = err instanceof Error ? err.message : String(err); } } } else { @@ -1005,35 +1044,83 @@ export class OpenCodeDriver implements Driver { return driver; } - private connect(baseUrl: string): void { - this.clientPromise = Promise.resolve(createOpencodeClient({ baseUrl })); + private connect(config: { baseUrl: string; headers?: Record }): OpencodeClient { + return (this.options.connect ?? createOpencodeClient)(config); } - /** Install `opencode`, start its server and connect — sessions started - * meanwhile wait on the same promise. */ - private launchInstalled(): void { - const { installOpenCode, port, log } = this.options; + /** Start `opencode serve` with the provider profiles it should have, and + * connect to it. */ + private async startServer(bin: string): Promise { + const profiles = this.providers; + const setup = serverSetup(profiles, process.env); + const start = this.options.startServer ?? startOpenCodeServer; + let server: OpenCodeServerHandle; + try { + server = await start({ + command: bin, + env: { ...process.env, ...setup.env }, + ...(this.options.port !== undefined ? { port: this.options.port } : {}), + }); + } catch (err) { + throw new Error(`The OpenCode server failed to start: ${err instanceof Error ? err.message : String(err)}`); + } + if (this.stopped) { + await server.close(); + throw new Error('the agent host is shutting down'); + } + this.server = server; + this.served = providersFingerprint(profiles); + const added = profiles.length > 0 ? `, with ${profiles.length} provider profile(s)` : ''; + this.options.log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})${added}`); + return this.connect({ baseUrl: server.url, headers: setup.headers }); + } + + /** Find (or install) `opencode`, start its server and connect — sessions + * started meanwhile wait on the same promise. */ + private launch(): void { const attempt = (async (): Promise => { - const bin = await installOpenCode!(); - let server: OpenCodeServerHandle; - try { - server = await startOpenCodeServer({ command: bin, ...(port !== undefined ? { port } : {}) }); - } catch (err) { - throw new Error(`The OpenCode server failed to start: ${err instanceof Error ? err.message : String(err)}`); - } - if (this.stopped) { - await server.close(); - throw new Error('the agent host is shutting down'); - } - this.server = server; - log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})`); - return createOpencodeClient({ baseUrl: server.url }); + this.bin ??= await this.options.installOpenCode!(); + return this.startServer(this.bin); })(); this.clientPromise = attempt; - attempt.catch((err: unknown) => { - log(`[opencode] unavailable: ${err instanceof Error ? err.message : String(err)}`); - if (this.clientPromise === attempt) this.clientPromise = null; - }); + attempt.then( + () => { + this.unavailable = undefined; + }, + (err: unknown) => { + this.options.log(`[opencode] unavailable: ${err instanceof Error ? err.message : String(err)}`); + if (this.clientPromise === attempt) this.clientPromise = null; + }, + ); + } + + /** The provider profiles to add to OpenCode's own providers. The server + * reads its config only when it starts, so a changed list restarts it: + * sessions on the old one end with an error and the bridge resumes them + * on the new one. */ + async setProviders(providers: ProviderBinding[]): Promise { + if (!this.manages) { + throw new Error('OpenCode runs on a server this bridge does not start (CODEDECK_OPENCODE_SERVER_URL), so it cannot add provider profiles to it'); + } + // A profile the bridge would not let a session use is not added either. + this.providers = providers.filter((p) => isValidProviderBaseUrl(p.baseUrl) && p.authToken !== '' && p.models.length > 0); + const apply = this.applying.then(() => this.applyProviders()); + this.applying = apply.catch(() => {}); + await apply; + } + + private async applyProviders(): Promise { + // A server still being installed or started is waited for: it may have + // read an older list. + await this.clientPromise?.catch(() => {}); + if (this.stopped || !this.server || !this.bin || providersFingerprint(this.providers) === this.served) return; + this.options.log('[opencode] restarting the server: its provider profiles changed'); + const old = this.server; + this.server = null; + this.clientPromise = null; + await old.close(); + this.launch(); + await this.clientPromise; } info(): AgentInfo { @@ -1043,16 +1130,28 @@ export class OpenCodeDriver implements Driver { modes: OPENCODE_MODES, efforts: [], defaultMode: DEFAULT_MODE, - // No subscription usage; sessions always use the providers configured - // on the OpenCode server itself. - supports: { models: true, usage: false, providers: false, gsd: true, interrupt: true, commands: true, plugins: true, mcp: true, tasks: true }, + // No subscription usage. Provider profiles add models to the server + // this driver starts; sessions are never bound to one. + supports: { + models: true, + usage: false, + providers: false, + providerModels: this.manages, + gsd: true, + interrupt: true, + commands: true, + plugins: true, + mcp: true, + tasks: true, + }, credentials: [], ...(this.unavailable ? { unavailableReason: this.unavailable } : {}), }; } startSession(params: StartSession, ctx: SessionContext): DriverSession { - if (!this.clientPromise && this.installs && !this.stopped) this.launchInstalled(); + if (params.provider) throw new Error('OpenCode offers provider profiles as models; a session is not bound to one'); + if (!this.clientPromise && this.installs && !this.stopped) this.launch(); if (!this.clientPromise) throw new Error(this.unavailable ?? NOT_CONFIGURED); if (params.mode !== undefined && !OPENCODE_MODES.some((m) => m.id === params.mode)) { throw new Error(`OpenCode has no mode '${params.mode}'`); @@ -1060,7 +1159,18 @@ export class OpenCodeDriver implements Driver { if (params.model && !splitModelId(params.model)) { throw new Error(`'${params.model}' is not an OpenCode provider/model id — choose one from its model list.`); } - return new OpenCodeSession(params, ctx, this.clientPromise, () => this.listModels()); + return new OpenCodeSession(params, ctx, this.clientPromise, { catalog: () => this.listModels() }); + } + + /** A profile is reached as an OpenAI-compatible provider: a session on + * it posts chat completions. */ + async checkProvider(provider: ProviderBinding, model: string): Promise { + return this.options.providerHttp ? checkToken(OPENAI_API, provider, model, this.options.providerHttp.post) : undefined; + } + + async listProviderModels(baseUrl: string, token: string): Promise { + if (!this.options.providerHttp) throw new Error('this host reads no provider endpoints'); + return listModels(OPENAI_API, baseUrl, token, this.options.providerHttp.get); } /** Best-effort model list from OpenCode's configured providers, and the diff --git a/packages/agent-host/src/drivers/opencode/module.ts b/packages/agent-host/src/drivers/opencode/module.ts index a0b332ae..694b48fb 100644 --- a/packages/agent-host/src/drivers/opencode/module.ts +++ b/packages/agent-host/src/drivers/opencode/module.ts @@ -7,6 +7,7 @@ */ import { installBinary } from '../../install/agentInstall'; import type { DriverEnv, DriverModule } from '../../sdk/module'; +import { providerHttp } from '../../sdk/net'; import { OpenCodeDriver } from './driver'; import { openCodeBinary } from './install'; import { resolveOpenCodePath } from './server'; @@ -29,6 +30,7 @@ export const openCodeModule: DriverModule = { lookupEnv: ctx.lookupEnv, ...(port !== undefined && Number.isInteger(port) ? { port } : {}), log: ctx.log, + providerHttp, }); }, runtime: { diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts new file mode 100644 index 00000000..e970a2a7 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -0,0 +1,103 @@ +/** + * Provider profiles as OpenCode providers. + * + * A profile adds a provider to OpenCode — beside OpenCode Zen and whatever + * the operator configured, which all stay: an OpenAI-compatible endpoint, + * its token and its models. OpenCode takes a custom provider only from its + * config, so the profiles reach the server this driver starts as config + * handed over in its environment, on top of the operator's files (which are + * never written): + * + * - each profile is the provider `codedeck-`, named after the + * profile, so its models are `codedeck-/` and the phone shows + * them under the profile's name; + * - each token sits in the server's environment only, referenced from the + * config, never in a file; + * - the server answers only with a password made for it, since its API + * reads its config — the tokens included — back to anyone on loopback + * who asks. + * + * The server reads its config when it starts, so a changed profile list + * means a new server (see the driver). + */ +import { createHash, randomBytes } from 'node:crypto'; +import { providerApiRoot } from '../../sdk/providerModels'; +import type { ProviderBinding } from '../../sdk/types'; + +/** What a profile's provider id starts with: no OpenCode provider (models.dev + * catalog or the operator's) is named so. */ +export const PROFILE_PROVIDER_PREFIX = 'codedeck-'; +/** The user name OpenCode's server expects with its password. */ +const SERVER_USERNAME = 'opencode'; + +/** The OpenCode provider id of a profile. */ +export function profileProviderId(profile: ProviderBinding): string { + return `${PROFILE_PROVIDER_PREFIX}${profile.id}`; +} + +/** The variable profile `index`'s token reaches the server in. */ +function tokenVariable(index: number): string { + return `CODEDECK_PROVIDER_KEY_${index}`; +} + +/** The provider entries for `profiles`, on top of `base` (an operator's own + * `OPENCODE_CONFIG_CONTENT`, whose providers stay). */ +export function providersConfig(profiles: ProviderBinding[], base: Record = {}): Record { + const own = (base.provider ?? {}) as Record; + const added = Object.fromEntries( + profiles.map((profile, index) => [ + profileProviderId(profile), + { + npm: '@ai-sdk/openai-compatible', + name: profile.label || profile.id, + options: { baseURL: providerApiRoot(profile.baseUrl), apiKey: `{env:${tokenVariable(index)}}` }, + models: Object.fromEntries(profile.models.map((m) => [m.id, m.label ? { name: m.label } : {}])), + }, + ]), + ); + return { ...base, provider: { ...own, ...added } }; +} + +/** What the server is started with to serve `profiles`, and how its client + * signs in. */ +export interface ServerSetup { + env: Record; + headers: Record; +} + +/** The environment and credentials of a server for `profiles`. `baseEnv` + * is the host's own environment. */ +export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.ProcessEnv): ServerSetup { + const password = randomBytes(24).toString('hex'); + const env: Record = { + OPENCODE_SERVER_USERNAME: SERVER_USERNAME, + OPENCODE_SERVER_PASSWORD: password, + }; + if (profiles.length > 0) { + env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(profiles, operatorConfig(baseEnv))); + profiles.forEach((profile, index) => { + env[tokenVariable(index)] = profile.authToken; + }); + } + const headers = { authorization: `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString('base64')}` }; + return { env, headers }; +} + +/** The config the operator handed OpenCode in the environment, if any. */ +function operatorConfig(baseEnv: NodeJS.ProcessEnv): Record { + const raw = baseEnv.OPENCODE_CONFIG_CONTENT; + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** What makes two profile lists need different servers: everything their + * config is built from. Hashed, so the tokens are not kept around in it. */ +export function providersFingerprint(profiles: ProviderBinding[]): string { + const parts = profiles.map((p) => [p.id, p.label, p.baseUrl, p.authToken, p.models]); + return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); +} diff --git a/packages/agent-host/src/drivers/opencode/server.ts b/packages/agent-host/src/drivers/opencode/server.ts index 22d1c93d..c1983942 100644 --- a/packages/agent-host/src/drivers/opencode/server.ts +++ b/packages/agent-host/src/drivers/opencode/server.ts @@ -73,6 +73,8 @@ export interface StartOpenCodeServerOptions { export interface OpenCodeServerHandle { url: string; pid: number | undefined; + /** Resolves when the process exits, however it ends. */ + exited: Promise; /** SIGTERM, wait, SIGKILL if it doesn't exit in time. Always resolves — * best-effort, matching every other shutdown-path cleanup in this repo. */ close(): Promise; @@ -128,6 +130,10 @@ export function startOpenCodeServer(opts: StartOpenCodeServerOptions): Promise((done) => { + if ((proc.exitCode ?? null) !== null || (proc.signalCode ?? null) !== null) done(); + else proc.once('exit', () => done()); + }); const timer = setTimeout(() => { if (settled) return; @@ -150,6 +156,7 @@ export function startOpenCodeServer(opts: StartOpenCodeServerOptions): Promise closeProcess(proc), }); return; diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index ea23c0e1..c4d2f054 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -59,8 +59,17 @@ export type AgentSupports = { models?: boolean, /** `usage-request` returns subscription usage for this agent's sessions. */ usage?: boolean, - /** Sessions may be bound to a custom provider profile (`providerId`). */ + /** + * Sessions may be bound to one of this agent's provider profiles + * (`providerId`), which then serves the whole session. + */ providers?: boolean, + /** + * This agent's provider profiles add their models to its own model + * list, beside every provider it already has; a session picks one of + * them as it would any other model. + */ + providerModels?: boolean, /** `gsd-request` returns GSD workflow state for this agent's sessions. */ gsd?: boolean, /** `interrupt` stops the running turn. */ @@ -241,6 +250,36 @@ export type BridgeMessage_Deserialize = credential: string, value: Secret, } } | +/** + * Check a provider profile's token with the endpoint, the way `agent` + * would use it, on `model`. Reply: `credential-checked`. + */ +{ kind: "check-provider"; payload: { + agent: string, + provider: ProviderBinding_Deserialize, + model: string, +} } | +/** + * The models the endpoint at `base_url` lists, read with `auth_token` + * the way `agent` speaks to it (a provider profile of `agent` being + * saved). Reply: `provider-models`, or `error` saying why there is no + * list. + */ +{ kind: "list-provider-models"; payload: { + agent: string, + baseUrl: string, + authToken: Secret, +} } | +/** + * The provider profiles of an agent whose catalog entry `supports` + * `providerModels`, all of them: sent after `initialize` and whenever + * one changes. The agent offers their models beside its own. Reply: + * `ack`. + */ +{ kind: "set-providers"; payload: { + agent: string, + providers: ProviderBinding_Deserialize[], +} } | /** Reply to `request-permission`. */ { kind: "permission-outcome"; payload: SelectOutcome } | /** Reply to `request-plan-approval`. */ @@ -373,6 +412,36 @@ export type BridgeMessage_Serialize = credential: string, value: Secret, } } | +/** + * Check a provider profile's token with the endpoint, the way `agent` + * would use it, on `model`. Reply: `credential-checked`. + */ +{ kind: "check-provider"; payload: { + agent: string, + provider: ProviderBinding_Serialize, + model: string, +} } | +/** + * The models the endpoint at `base_url` lists, read with `auth_token` + * the way `agent` speaks to it (a provider profile of `agent` being + * saved). Reply: `provider-models`, or `error` saying why there is no + * list. + */ +{ kind: "list-provider-models"; payload: { + agent: string, + baseUrl: string, + authToken: Secret, +} } | +/** + * The provider profiles of an agent whose catalog entry `supports` + * `providerModels`, all of them: sent after `initialize` and whenever + * one changes. The agent offers their models beside its own. Reply: + * `ack`. + */ +{ kind: "set-providers"; payload: { + agent: string, + providers: ProviderBinding_Serialize[], +} } | /** Reply to `request-permission`. */ { kind: "permission-outcome"; payload: SelectOutcome } | /** Reply to `request-plan-approval`. */ @@ -683,10 +752,17 @@ export type HostMessage_Deserialize = toggles?: boolean, projectWide?: boolean, } } | -/** Reply to `check-credential`; absent `valid` = it could not be checked. */ +/** + * Reply to `check-credential` and `check-provider`; absent `valid` = it + * could not be checked. + */ { kind: "credential-checked"; payload: { valid?: boolean | null, } } | +/** Reply to `list-provider-models`: never empty. */ +{ kind: "provider-models"; payload: { + models: ProviderModel_Deserialize[], +} } | /** A notification: no frame id, no reply. */ { kind: "session-event"; payload: { sessionId: string, @@ -755,10 +831,17 @@ export type HostMessage_Serialize = toggles?: boolean, projectWide?: boolean, } } | -/** Reply to `check-credential`; absent `valid` = it could not be checked. */ +/** + * Reply to `check-credential` and `check-provider`; absent `valid` = it + * could not be checked. + */ { kind: "credential-checked"; payload: { valid?: boolean | null, } } | +/** Reply to `list-provider-models`: never empty. */ +{ kind: "provider-models"; payload: { + models: ProviderModel_Serialize[], +} } | /** A notification: no frame id, no reply. */ { kind: "session-event"; payload: { sessionId: string, @@ -1075,21 +1158,34 @@ export type PluginMarketplace = { source: string, }; -/** A custom provider profile a session is bound to for its whole life. */ +/** + * A provider profile, token included: one a session is bound to for its + * whole life, or one an agent offers the models of (`set-providers`). + */ export type ProviderBinding = ProviderBinding_Serialize | ProviderBinding_Deserialize; -/** A custom provider profile a session is bound to for its whole life. */ +/** + * A provider profile, token included: one a session is bound to for its + * whole life, or one an agent offers the models of (`set-providers`). + */ export type ProviderBinding_Deserialize = { id: string, + /** The name the user gave it, for showing where a model comes from. */ + label: string, baseUrl: string, authToken: Secret, models: ProviderModel_Deserialize[], defaultModel?: string | null, }; -/** A custom provider profile a session is bound to for its whole life. */ +/** + * A provider profile, token included: one a session is bound to for its + * whole life, or one an agent offers the models of (`set-providers`). + */ export type ProviderBinding_Serialize = { id: string, + /** The name the user gave it, for showing where a model comes from. */ + label: string, baseUrl: string, authToken: Secret, models: ProviderModel_Serialize[], diff --git a/packages/agent-host/src/host/host.ts b/packages/agent-host/src/host/host.ts index c469afc0..b359c0a5 100644 --- a/packages/agent-host/src/host/host.ts +++ b/packages/agent-host/src/host/host.ts @@ -228,6 +228,24 @@ export class AgentHost { const valid = await driver.checkCredential?.(message.payload.credential, message.payload.value); return { kind: 'credential-checked', payload: valid === undefined ? {} : { valid } }; } + case 'check-provider': { + const { agent, provider, model } = message.payload; + const valid = await this.driver(agent).checkProvider?.(provider, model); + return { kind: 'credential-checked', payload: valid === undefined ? {} : { valid } }; + } + case 'list-provider-models': { + const { agent, baseUrl, authToken } = message.payload; + const driver = this.driver(agent); + if (!driver.listProviderModels) throw new Error(`${driver.info().displayName} cannot read a provider's models`); + const models = await driver.listProviderModels(baseUrl, authToken); + return { kind: 'provider-models', payload: { models: models.map((m) => ({ id: m.id, ...(m.label ? { label: m.label } : {}) })) } }; + } + case 'set-providers': { + const driver = this.driver(message.payload.agent); + if (!driver.setProviders) throw new Error(`${driver.info().displayName} does not add provider profiles to its models`); + await driver.setProviders(message.payload.providers); + return ack(); + } default: throw new Error(`unsupported request ${(message as { kind: string }).kind}`); } diff --git a/packages/agent-host/src/sdk/__tests__/provider.test.ts b/packages/agent-host/src/sdk/__tests__/provider.test.ts new file mode 100644 index 00000000..1d6ab104 --- /dev/null +++ b/packages/agent-host/src/sdk/__tests__/provider.test.ts @@ -0,0 +1,39 @@ +/** + * The base URL rule a driver checks again before it puts a profile's token + * on a connection — the same as the bridge's (`crates/protocol`). + */ +import { describe, expect, it } from 'vitest'; +import { isValidProviderBaseUrl } from '../provider'; + +describe('isValidProviderBaseUrl', () => { + it('takes https, this machine, and an address on the user\'s own network', () => { + for (const ok of [ + 'https://openrouter.ai/api', + 'http://localhost:11434/v1', + 'http://127.0.0.1:1234', + 'http://[::1]:8080', + 'http://192.168.1.2:3458', + 'http://10.0.0.7/v1', + 'http://172.16.0.1', + 'http://100.101.102.103', + 'http://[fd12::1]:3000', + ]) { + expect(isValidProviderBaseUrl(ok), ok).toBe(true); + } + }); + + it('refuses http anywhere else, a name, and userinfo', () => { + for (const refused of [ + 'http://api.example.com', + 'http://8.8.8.8', + 'http://172.32.0.1', + 'http://[2001:db8::1]', + 'http://router.local', + 'http://user:pw@192.168.1.2', + 'ftp://192.168.1.2', + 'not a url', + ]) { + expect(isValidProviderBaseUrl(refused), refused).toBe(false); + } + }); +}); diff --git a/packages/agent-host/src/sdk/__tests__/providerModels.test.ts b/packages/agent-host/src/sdk/__tests__/providerModels.test.ts new file mode 100644 index 00000000..d295b140 --- /dev/null +++ b/packages/agent-host/src/sdk/__tests__/providerModels.test.ts @@ -0,0 +1,141 @@ +/** + * Reading the models an endpoint serves — a provider's own API or a gateway + * in front of several — from its `/v1/models`. + */ +import { describe, expect, it, vi } from 'vitest'; +import { fetchProviderModels, MAX_ENDPOINT_MODELS, parseProviderModels, providerModelsUrl } from '../providerModels'; + +const hex = (text: string): string => Buffer.from(text, 'utf8').toString('hex'); + +/** An endpoint answering with `body`. */ +const answering = (body: unknown, status = 200) => + vi.fn(async (_url: string, _headers: Record) => ({ + status, + text: typeof body === 'string' ? body : JSON.stringify(body), + })); + +describe('providerModelsUrl', () => { + it('lists under /v1 of the root, unless the root already ends in it', () => { + expect(providerModelsUrl('http://gateway.example:3458')).toBe('http://gateway.example:3458/v1/models'); + expect(providerModelsUrl('http://gateway.example:3458/')).toBe('http://gateway.example:3458/v1/models'); + expect(providerModelsUrl('https://openrouter.ai/api')).toBe('https://openrouter.ai/api/v1/models'); + expect(providerModelsUrl('https://gateway.example/v1')).toBe('https://gateway.example/v1/models'); + }); +}); + +describe('parseProviderModels', () => { + it('reads the shapes endpoints answer with, keeping what says something', () => { + expect(parseProviderModels({ data: [{ id: 'kimi-k2' }, { id: 'glm-4.6', context_length: 200_000 }] })).toEqual([ + { id: 'kimi-k2' }, + { id: 'glm-4.6', contextWindow: 200_000 }, + ]); + expect(parseProviderModels(['a', 'b'])).toEqual([{ id: 'a' }, { id: 'b' }]); + expect(parseProviderModels({ models: [{ id: 'x', name: 'X' }] })).toEqual([{ id: 'x', label: 'X' }]); + // A name that only repeats the id says nothing. + expect(parseProviderModels({ data: [{ id: 'x', name: 'x' }] })).toEqual([{ id: 'x' }]); + }); + + it('drops duplicates, blanks and anything that is not a model', () => { + expect(parseProviderModels({ data: [{ id: 'a' }, { id: 'a' }, { id: ' ' }, { no: 'id' }, 7] })).toEqual([{ id: 'a' }]); + expect(parseProviderModels({ error: 'nope' })).toEqual([]); + expect(parseProviderModels('not a list')).toEqual([]); + expect(parseProviderModels(null)).toEqual([]); + }); + + it('keeps a routed id whole and names its upstream as the provider', () => { + expect( + parseProviderModels({ + data: [ + { id: 'Claude Code API/claude-sonnet-5', display_name: 'Claude Sonnet 5' }, + { id: 'Z.ai (Global) - Coding Plan/glm-5.2', display_name: 'Z.ai (Global) - Coding Plan/GLM-5.2' }, + { id: 'deepseek/deepseek-chat' }, + ], + }), + ).toEqual([ + { id: 'Claude Code API/claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'Claude Code API' }, + { id: 'Z.ai (Global) - Coding Plan/glm-5.2', label: 'GLM-5.2', provider: 'Z.ai (Global) - Coding Plan' }, + { id: 'deepseek/deepseek-chat', provider: 'deepseek' }, + ]); + }); + + it("decodes claude-code-router's ids for Claude Code and reads what it says about context", () => { + expect( + parseProviderModels({ + data: [ + { + id: `anthropic/claude-ccr-h${hex('Z.ai (Global) - Coding Plan/glm-5.3-flash')}[1m]`, + display_name: 'Z.ai (Global) - Coding Plan/GLM-5.3-Flash (1M context)', + max_input_tokens: 1_310_720, + capabilities: { context_window: { max_input_tokens: 1_310_720, supports_1m_context: true } }, + }, + { + id: `anthropic/claude-ccr-h${hex('Golem/local-model')}`, + display_name: 'Golem/local-model', + // A size the router does not know. + max_input_tokens: 0, + }, + ], + }), + ).toEqual([ + { + id: 'Z.ai (Global) - Coding Plan/glm-5.3-flash', + label: 'GLM-5.3-Flash (1M context)', + provider: 'Z.ai (Global) - Coding Plan', + contextWindow: 1_310_720, + oneMillionContext: true, + }, + { id: 'Golem/local-model', label: 'local-model', provider: 'Golem' }, + ]); + }); + + it('lists a model once when it comes with and without the [1m] marker', () => { + expect(parseProviderModels({ data: [{ id: 'claude-opus-5' }, { id: 'claude-opus-5[1m]' }] })).toEqual([ + { id: 'claude-opus-5', oneMillionContext: true }, + ]); + }); + + it('keeps a phone-sized list', () => { + const many = Array.from({ length: MAX_ENDPOINT_MODELS + 5 }, (_, i) => ({ id: `m${i}` })); + expect(parseProviderModels({ data: many })).toHaveLength(MAX_ENDPOINT_MODELS); + }); +}); + +describe('fetchProviderModels', () => { + it('asks with the token and the extra headers', async () => { + const httpGet = answering({ data: [{ id: 'kimi-k2' }] }); + const models = await fetchProviderModels('https://gw.example/', { + token: 'sk-1', + headers: { 'user-agent': 'ua' }, + httpGet, + log: () => {}, + tag: '[t]', + }); + expect(models).toEqual([{ id: 'kimi-k2' }]); + expect(httpGet).toHaveBeenCalledWith('https://gw.example/v1/models', { 'user-agent': 'ua', authorization: 'Bearer sk-1' }); + }); + + it('answers nothing — and says why — when the endpoint refuses, breaks or lists nothing', async () => { + const logs: string[] = []; + const options = (httpGet: Parameters[1]['httpGet']) => ({ + httpGet, + log: (line: string) => logs.push(line), + tag: '[t]', + }); + expect(await fetchProviderModels('https://gw.example', options(answering({}, 401)))).toBeUndefined(); + expect(await fetchProviderModels('https://gw.example', options(answering('')))).toBeUndefined(); + expect(await fetchProviderModels('https://gw.example', options(answering({ data: [] })))).toBeUndefined(); + expect( + await fetchProviderModels( + 'https://gw.example', + options(async () => { + throw new Error('ECONNREFUSED'); + }), + ), + ).toBeUndefined(); + expect(await fetchProviderModels('not a url', options(answering({ data: [{ id: 'a' }] })))).toBeUndefined(); + expect(logs.some((line) => /^\[t\] .*refused the token \(HTTP 401\)/.test(line))).toBe(true); + expect(logs.some((line) => /lists no models/.test(line))).toBe(true); + expect(logs.some((line) => /ECONNREFUSED/.test(line))).toBe(true); + expect(logs.some((line) => /not a URL/.test(line))).toBe(true); + }); +}); diff --git a/packages/agent-host/src/sdk/driver.ts b/packages/agent-host/src/sdk/driver.ts index 55c38b0d..38b2396a 100644 --- a/packages/agent-host/src/sdk/driver.ts +++ b/packages/agent-host/src/sdk/driver.ts @@ -24,6 +24,7 @@ import type { PlanOutcome, PluginAction, PluginMarketplace, + ProviderBinding, QuestionOutcome, QuestionSpec, SelectOutcome, @@ -34,6 +35,7 @@ import type { StartSession, UsageData, } from './types'; +import type { EndpointModel } from './providerModels'; /** What a running session can do toward the bridge. */ export interface SessionContext { @@ -140,6 +142,19 @@ export interface Driver { /** Check a credential value with its provider: true/false, or undefined * when it could not be checked. */ checkCredential?(credential: string, value: string): Promise; + /** Check one of this agent's provider profiles: its token, with the + * smallest request on the API the agent speaks to it, on `model` + * (`sdk/providerCheck`). True/false, or undefined when it could not be + * checked. */ + checkProvider?(provider: ProviderBinding, model: string): Promise; + /** The models a provider profile's endpoint lists, read the way this + * agent's API signs in (`sdk/providerApi`). Rejects with the reason + * there is no list. */ + listProviderModels?(baseUrl: string, token: string): Promise; + /** For an agent whose catalog entry `supports.providerModels`: its + * provider profiles, all of them, whenever one changes. Their models are + * offered beside the agent's own; nothing the agent already has goes. */ + setProviders?(providers: ProviderBinding[]): Promise; /** * Delete the agent's own record of a conversation (an `info` * `nativeSessionId` it reported, run in `cwd`): its transcript files, or diff --git a/packages/agent-host/src/sdk/net.ts b/packages/agent-host/src/sdk/net.ts index 8bf997b6..21c12c4a 100644 --- a/packages/agent-host/src/sdk/net.ts +++ b/packages/agent-host/src/sdk/net.ts @@ -1,7 +1,7 @@ /** - * The host's own outbound HTTP (credential checks). Plain `fetch`: the - * bridge's Tor proxy covers its relay traffic only, not agent-side requests. - * A seam so tests never touch the network. + * The host's own outbound HTTP (credential checks, provider endpoints). + * Plain `fetch`: the bridge's Tor proxy covers its relay traffic only, not + * agent-side requests. A seam so tests never touch the network. */ export interface HttpResponse { @@ -24,3 +24,51 @@ export const httpGet: HttpGet = async (url, headers) => { const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15_000) }); return { status: res.status, text: await res.text() }; }; + +/** The most of a provider's answer that is read. Some providers describe + * every model at length (OpenRouter's list is a few megabytes); past this + * the answer is refused rather than buffered without bound. */ +export const MAX_PROVIDER_ANSWER_BYTES = 16 * 1024 * 1024; + +/** + * A GET to a provider profile's endpoint, carrying its token. A redirect is + * not followed but answered as its own status: it could lead the token to + * another host, or off https — only the URL the profile names may see it. + */ +export const providerGet: HttpGet = async (url, headers) => { + const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal: AbortSignal.timeout(20_000) }); + return { status: res.status, text: await readCapped(res) }; +}; + +/** A POST to a provider profile's endpoint; redirects as for `providerGet`. */ +export const providerPost: HttpPost = async (url, headers, body) => { + const res = await fetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); + await res.body?.cancel(); + return { status: res.status }; +}; + +/** Outbound HTTP to provider profiles' endpoints, as drivers take it. */ +export interface ProviderHttp { + get: HttpGet; + post: HttpPost; +} + +export const providerHttp: ProviderHttp = { get: providerGet, post: providerPost }; + +async function readCapped(res: Response): Promise { + if (!res.body) return ''; + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_PROVIDER_ANSWER_BYTES) { + await reader.cancel(); + throw new Error('the answer is too large'); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/packages/agent-host/src/sdk/provider.ts b/packages/agent-host/src/sdk/provider.ts index d18e0e81..4d469854 100644 --- a/packages/agent-host/src/sdk/provider.ts +++ b/packages/agent-host/src/sdk/provider.ts @@ -8,11 +8,15 @@ /** The message shown when a base URL is refused (the bridge shows the same). */ export const PROVIDER_BASE_URL_ERROR = - 'Base URL must be https:// (http:// is allowed only for localhost, 127.0.0.1 or [::1])'; + 'Base URL must be https:// (http:// is allowed only for this machine — localhost, 127.0.0.1, [::1] — ' + + 'or an address on your own network, such as 192.168.1.10)'; -/** https anywhere, or http ONLY on loopback — a local model server has no - * cert and its traffic never leaves the machine; anything else is a network - * hop carrying a bearer token. */ +/** https anywhere; http to this machine, whose traffic never leaves it; or + * http to an IP address of the user's own network (a gateway at home), + * where the user chose to let the token cross that network in cleartext. + * Mirrors `crates/protocol`'s `is_valid_provider_base_url`: private means + * 10/8, 172.16/12, 192.168/16, 100.64/10 and fc00::/7, written as + * addresses — never a name, which DNS could point anywhere. */ export function isValidProviderBaseUrl(raw: string): boolean { let url: URL; try { @@ -21,6 +25,21 @@ export function isValidProviderBaseUrl(raw: string): boolean { return false; } if (url.protocol === 'https:') return url.host !== ''; - if (url.protocol !== 'http:') return false; - return ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname.toLowerCase()); + if (url.protocol !== 'http:' || url.username !== '' || url.password !== '') return false; + const host = url.hostname.toLowerCase(); + return ['localhost', '127.0.0.1', '[::1]'].includes(host) || isPrivateNetworkAddress(host); +} + +/** `host` as the URL parser normalises it: dotted IPv4, or bracketed IPv6. */ +function isPrivateNetworkAddress(host: string): boolean { + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127); + } + if (host.startsWith('[') && host.endsWith(']')) { + const first = host.slice(1, -1).split(':')[0] ?? ''; + return first !== '' && (Number.parseInt(first, 16) & 0xfe00) === 0xfc00; + } + return false; } diff --git a/packages/agent-host/src/sdk/providerApi.ts b/packages/agent-host/src/sdk/providerApi.ts new file mode 100644 index 00000000..0aa13d10 --- /dev/null +++ b/packages/agent-host/src/sdk/providerApi.ts @@ -0,0 +1,72 @@ +/** + * The APIs a provider profile's endpoint can speak, as an agent speaks + * them: how it signs in, how its models are listed, and the smallest + * request that proves the token. A profile belongs to one agent, and a + * driver picks the API its agent uses — an endpoint compatible with one is + * not necessarily compatible with another. + */ +import type { HttpGet, HttpPost } from './net'; +import { providerApiRoot, readProviderModels, type EndpointModel } from './providerModels'; +import type { ProviderBinding } from './types'; + +export interface ProviderApi { + /** The headers that carry `token` on this API. */ + auth(token: string): Record; + /** The path under the endpoint's `/v1` root a one-token request goes to. */ + completionPath: string; +} + +/** OpenAI's API (chat completions), as OpenAI-compatible clients call it. */ +export const OPENAI_API: ProviderApi = { + auth: (token) => ({ authorization: `Bearer ${token}` }), + completionPath: 'chat/completions', +}; + +/** Anthropic's Messages API. The token goes in both headers Anthropic-style + * endpoints read it from: `x-api-key` (Anthropic's own) and + * `Authorization` (most gateways). */ +export const ANTHROPIC_API: ProviderApi = { + auth: (token) => ({ 'x-api-key': token, authorization: `Bearer ${token}`, 'anthropic-version': '2023-06-01' }), + completionPath: 'messages', +}; + +/** The models the endpoint lists, read the way `api` signs in. Rejects + * with the reason (see `readProviderModels`). */ +export function listModels(api: ProviderApi, baseUrl: string, token: string, get: HttpGet): Promise { + return readProviderModels(baseUrl, { headers: api.auth(token), httpGet: get }); +} + +/** + * What a check's HTTP status says about the token. 401/403: rejected. + * Success, or an error the provider only returns once it has accepted the + * credentials (a malformed request, a rate limit): valid. Anything else — + * 404 from a wrong base URL or an API the endpoint does not speak, a + * redirect, a 5xx — never reached the credential check, so it proves + * nothing either way. + */ +export function tokenVerdict(status: number): boolean | undefined { + if (status === 401 || status === 403) return false; + if ((status >= 200 && status < 300) || status === 400 || status === 422 || status === 429) return true; + return undefined; +} + +/** Check a profile's token with one token of `model` on `api`. Undefined + * when it could not be checked. */ +export async function checkToken( + api: ProviderApi, + provider: ProviderBinding, + model: string, + post: HttpPost, +): Promise { + const body = JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }); + try { + const res = await post( + `${providerApiRoot(provider.baseUrl)}/${api.completionPath}`, + { 'content-type': 'application/json', ...api.auth(provider.authToken) }, + body, + ); + return tokenVerdict(res.status); + } catch { + return undefined; + } +} diff --git a/packages/agent-host/src/sdk/providerModels.ts b/packages/agent-host/src/sdk/providerModels.ts new file mode 100644 index 00000000..3cfe1918 --- /dev/null +++ b/packages/agent-host/src/sdk/providerModels.ts @@ -0,0 +1,206 @@ +/** + * The models an endpoint serves, read from its `/v1/models`. + * + * The endpoint is whatever an agent is pointed at: a provider's own API + * (DeepSeek, Moonshot, OpenRouter) or a gateway in front of several + * (claude-code-router, LiteLLM). Both answer the same OpenAI-shaped list, + * and only the endpoint can say what it serves — so drivers ask it rather + * than keep model names of their own. + * + * A gateway's ids commonly name the upstream before the model + * (`Z.ai (Global) - Coding Plan/glm-5.3-flash`): that prefix is the + * gateway's own routing key, needed verbatim on every request, so the id is + * kept whole and the prefix is reported as the model's `provider` for + * grouping only. A provider's own vendor-prefixed ids (OpenRouter's + * `deepseek/deepseek-chat`) read the same way. + */ +import type { HttpGet } from './net'; + +/** One model an endpoint serves. */ +export interface EndpointModel { + /** What a request names the model by — never rewritten. */ + id: string; + /** The endpoint's own name for it, without the `provider` prefix; absent + * when it gives none (or gives the id again). */ + label?: string; + /** The part of the id before its first `/`, when there is one. */ + provider?: string; + /** Input tokens the model accepts, when the endpoint says. */ + contextWindow?: number; + /** Whether it has the 1M-token window, when the endpoint says so in so + * many words (a flag, or the `[1m]` id marker) rather than by size. */ + oneMillionContext?: boolean; +} + +/** How many models are kept: the list is for a phone screen. */ +export const MAX_ENDPOINT_MODELS = 200; + +/** + * An endpoint's versioned API root: its base URL with `/v1` appended, + * unless it already ends in `/v1` (both spellings are common, and every + * agent that takes a base URL accepts one or the other). OpenAI-style + * clients post to `/chat/completions`. + */ +export function providerApiRoot(baseUrl: string): string { + const base = baseUrl.replace(/\/+$/, ''); + return new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; +} + +/** Where an endpoint lists its models: `/models`. */ +export function providerModelsUrl(baseUrl: string): string { + return `${providerApiRoot(baseUrl)}/models`; +} + +/** claude-code-router's id for a model as it lists it to Claude Code: + * `anthropic/claude-ccr-h`, the hex being the router's own + * `/` id in UTF-8. */ +const CCR_ENCODED_ID = /^(?:anthropic\/)?claude-ccr-h((?:[0-9a-f]{2})+)$/i; +/** The marker a 1M-context variant's id carries. */ +const ONE_MILLION_MARKER = /\[1m\]$/i; + +/** + * The models in an endpoint's answer: the OpenAI-shaped `data` array, a + * `models` array or a bare array, of objects with an `id` or of plain id + * strings. Duplicates, blanks and anything that is not a model are + * dropped; an answer that is not a list yields nothing. + */ +export function parseProviderModels(body: unknown): EndpointModel[] { + const list = Array.isArray(body) + ? body + : typeof body === 'object' && body !== null + ? ((body as { data?: unknown }).data ?? (body as { models?: unknown }).models) + : undefined; + if (!Array.isArray(list)) return []; + const models: EndpointModel[] = []; + const seen = new Set(); + for (const raw of list) { + const entry = typeof raw === 'object' && raw !== null ? (raw as Record) : undefined; + const listed = typeof raw === 'string' ? raw : entry?.id; + if (typeof listed !== 'string' || listed.trim() === '') continue; + const marked = ONE_MILLION_MARKER.test(listed); + const bare = listed.replace(ONE_MILLION_MARKER, ''); + const hex = CCR_ENCODED_ID.exec(bare)?.[1]; + const id = (hex && Buffer.from(hex, 'hex').toString('utf8')) || bare; + if (seen.has(id)) { + // The same model listed with and without the 1M marker: one entry, + // which has the window. + if (marked) { + const known = models.find((m) => m.id === id); + if (known && known.oneMillionContext === undefined) known.oneMillionContext = true; + } + continue; + } + seen.add(id); + const slash = id.indexOf('/'); + const provider = slash > 0 && slash < id.length - 1 ? id.slice(0, slash) : undefined; + const label = entry ? labelOf(entry, id, provider) : undefined; + const contextWindow = entry ? contextWindowOf(entry) : undefined; + const flag = entry ? oneMillionFlag(entry) : undefined; + const oneMillionContext = flag ?? (marked ? true : undefined); + models.push({ + id, + ...(label ? { label } : {}), + ...(provider ? { provider } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(oneMillionContext !== undefined ? { oneMillionContext } : {}), + }); + if (models.length >= MAX_ENDPOINT_MODELS) break; + } + return models; +} + +/** The entry's own name (`display_name`, else `name`), without a leading + * `/`; nothing when it only repeats the id. */ +function labelOf(entry: Record, id: string, provider: string | undefined): string | undefined { + const named = [entry.display_name, entry.name].find((n): n is string => typeof n === 'string' && n.trim() !== ''); + if (!named) return undefined; + const label = provider && named.startsWith(`${provider}/`) ? named.slice(provider.length + 1) : named; + return label === id ? undefined : label; +} + +/** A model's context size, when the endpoint states one (they spell it + * several ways; 0 means "unknown" to some). */ +function contextWindowOf(entry: Record): number | undefined { + const window = (entry.capabilities as { context_window?: Record } | undefined)?.context_window; + for (const value of [ + entry.context_window, + entry.context_length, + entry.contextWindow, + entry.max_input_tokens, + window?.max_input_tokens, + ]) { + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; + } + return undefined; +} + +function oneMillionFlag(entry: Record): boolean | undefined { + const window = (entry.capabilities as { context_window?: Record } | undefined)?.context_window; + return typeof window?.supports_1m_context === 'boolean' ? window.supports_1m_context : undefined; +} + +export interface FetchProviderModelsOptions { + /** Sent as a Bearer token, when given. */ + token?: string; + /** Any further request headers. */ + headers?: Record; + httpGet: HttpGet; + /** Where a failure is reported; `tag` prefixes each line. */ + log: (message: string) => void; + tag: string; +} + +/** + * The models the endpoint at `baseUrl` serves. Rejects with the reason, in + * words for a person, when there is no list: not a URL, unreachable, + * refused, an answer that is not a model list, or an empty one. + */ +export async function readProviderModels( + baseUrl: string, + options: Pick, +): Promise { + let url: string; + try { + url = providerModelsUrl(baseUrl); + } catch { + throw new Error(`'${baseUrl}' is not a URL`); + } + let response: Awaited>; + try { + response = await options.httpGet(url, { + ...options.headers, + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), + }); + } catch (error) { + throw new Error(`${url} could not be read (${error instanceof Error ? error.message : String(error)})`); + } + if (response.status === 401 || response.status === 403) { + throw new Error(`the provider refused the token (HTTP ${response.status})`); + } + if (response.status < 200 || response.status >= 300) throw new Error(`${url} answered HTTP ${response.status}`); + let body: unknown; + try { + body = JSON.parse(response.text ?? '') as unknown; + } catch { + throw new Error(`${url} did not answer with a model list`); + } + const models = parseProviderModels(body); + if (models.length === 0) throw new Error('it lists no models'); + return models; +} + +/** + * The models the endpoint at `baseUrl` serves, or `undefined` when they + * could not be read (see `readProviderModels`) — the reason is logged. + */ +export async function fetchProviderModels( + baseUrl: string, + options: FetchProviderModelsOptions, +): Promise { + try { + return await readProviderModels(baseUrl, options); + } catch (error) { + options.log(`${options.tag} no model list from ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +}