diff --git a/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt b/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt
index 314c6fde..2f823337 100644
--- a/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt
+++ b/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt
@@ -10,6 +10,7 @@ internal enum class LlmCompatibilityFamily {
MiMo,
Moonshot,
OpenRouter,
+ Requesty,
}
internal enum class ReasoningDisableStyle {
@@ -40,6 +41,11 @@ internal object ModelCapabilitiesResolver {
reasoningDisableStyle = ReasoningDisableStyle.OpenRouterReasoningEffortNone,
)
+ isRequesty(host) -> ModelCapabilities(
+ family = LlmCompatibilityFamily.Requesty,
+ reasoningDisableStyle = ReasoningDisableStyle.OpenRouterReasoningEffortNone,
+ )
+
isDeepSeek(host, model) -> ModelCapabilities(
family = LlmCompatibilityFamily.DeepSeek,
reasoningDisableStyle = ReasoningDisableStyle.DeepSeekThinkingDisabled,
@@ -71,6 +77,9 @@ internal object ModelCapabilitiesResolver {
private fun isOpenRouter(host: String, model: String): Boolean =
"openrouter" in host || model.startsWith("openrouter/") || model.startsWith("openrouter:")
+ private fun isRequesty(host: String): Boolean =
+ "requesty" in host
+
private fun isDeepSeek(host: String, model: String): Boolean =
"deepseek" in host || "deepseek" in model
diff --git a/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt b/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt
index ea50150f..f0d25865 100644
--- a/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt
+++ b/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt
@@ -131,8 +131,12 @@ object ProviderModelCatalogClient {
): FetchModelsResult = withContext(Dispatchers.IO) {
try {
val definition = PiProviderCatalog.resolve(config.piProviderId)
- val providerModels = runCatching { fetchProviderModels(config) }.getOrElse { error ->
- FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.")
+ val providerModels = if (definition.id == "requesty") {
+ fetchRequestyModels(config)
+ } else {
+ runCatching { fetchProviderModels(config) }.getOrElse { error ->
+ FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.")
+ }
}
if (providerModels.models.isNotEmpty()) return@withContext providerModels
@@ -189,14 +193,30 @@ object ProviderModelCatalogClient {
}
}
- private fun fetchProviderModels(config: LlmProviderConfig): FetchModelsResult {
+ // Requesty lists its curated managed models first, followed by the full catalog.
+ private fun fetchRequestyModels(config: LlmProviderConfig): FetchModelsResult {
+ val managedModels = runCatching { fetchProviderModels(config, endpointSuffix = "/managed") }
+ .getOrElse { error -> FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.") }
+ val catalogModels = runCatching { fetchProviderModels(config) }
+ .getOrElse { error -> FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.") }
+ val models = (managedModels.models + catalogModels.models).distinctBy { it.lowercase() }
+ return FetchModelsResult(
+ models,
+ if (models.isEmpty()) catalogModels.error ?: managedModels.error else null,
+ )
+ }
+
+ private fun fetchProviderModels(
+ config: LlmProviderConfig,
+ endpointSuffix: String = "",
+ ): FetchModelsResult {
val baseUrl = config.baseUrl.trim().trimEnd('/')
val modelsUrl = when {
baseUrl.endsWith("/responses") -> baseUrl.replace("/responses", "/models")
baseUrl.endsWith("/chat/completions") -> baseUrl.replace("/chat/completions", "/models")
baseUrl.endsWith("/v1") -> "$baseUrl/models"
else -> "$baseUrl/models"
- }
+ } + endpointSuffix
val connection = URL(modelsUrl).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
diff --git a/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt b/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt
index 12776fda..6fca44cb 100644
--- a/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt
+++ b/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt
@@ -1968,6 +1968,7 @@ class SessionExecutionManager(
private fun AppSettings.supportsVisibleReasoningTrace(): Boolean {
return baseUrl.contains("deepseek", ignoreCase = true) ||
baseUrl.contains("openrouter", ignoreCase = true) ||
+ baseUrl.contains("requesty", ignoreCase = true) ||
modelId.contains("deepseek", ignoreCase = true) ||
modelId.contains("openrouter", ignoreCase = true)
}
diff --git a/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt b/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt
index 441ddef7..1f4c48b0 100644
--- a/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt
+++ b/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt
@@ -158,6 +158,42 @@ class ProviderModelCatalogClientTest {
}
}
+ @Test
+ fun requestyListsManagedModelsBeforeFullCatalog() = runBlocking {
+ val server = MockWebServer()
+ server.enqueue(
+ MockResponse()
+ .addHeader("Content-Type", "application/json")
+ .setBody("""{"data":[{"id":"claude-sonnet-4-5"}]}""")
+ )
+ server.enqueue(
+ MockResponse()
+ .addHeader("Content-Type", "application/json")
+ .setBody("""{"data":[{"id":"openai/gpt-4o-mini"},{"id":"claude-sonnet-4-5"}]}""")
+ )
+ server.start()
+
+ try {
+ val result = ProviderModelCatalogClient.fetchModels(
+ LlmProviderConfig(
+ providerId = "requesty",
+ name = "Requesty",
+ piProviderId = "requesty",
+ apiKey = "test-key",
+ baseUrl = server.url("/v1").toString(),
+ modelId = "",
+ )
+ )
+
+ assertEquals(null, result.error)
+ assertEquals(listOf("claude-sonnet-4-5", "openai/gpt-4o-mini"), result.models)
+ assertEquals("/v1/models/managed", server.takeRequest().path)
+ assertEquals("/v1/models", server.takeRequest().path)
+ } finally {
+ server.shutdown()
+ }
+ }
+
@Test
fun failedProviderRequestFallsBackToModelsDev() = runBlocking {
val server = MockWebServer()
diff --git a/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt b/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt
index 270a6431..8bd3cd25 100644
--- a/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt
+++ b/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt
@@ -117,6 +117,24 @@ class PiProviderMapperTest {
assertFalse(builtIn.has("compatibility_mode"))
}
+ @Test
+ fun requestyMapsToCustomOpenAiCompletionsProvider() {
+ val config = AppSettings(
+ piProviderId = "requesty",
+ providerConfigId = "requesty",
+ apiKey = "requesty-key",
+ baseUrl = "https://router.requesty.ai/v1",
+ modelId = "openai/gpt-4o-mini",
+ ).toPiModelConfig()
+
+ assertEquals("custom", config.providerType)
+ assertEquals("openai-completions", config.piApi)
+ assertTrue(config.piProviderId.startsWith("aether-"))
+ assertEquals("https://router.requesty.ai/v1", config.baseUrl)
+ assertEquals("openai/gpt-4o-mini", config.modelId)
+ assertEquals("requesty-key", config.apiKey)
+ }
+
@Test
fun legacyNoneReasoningEffortMigratesToPiOff() {
assertEquals("off", AppSettings(
diff --git a/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt b/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt
index 1030ac7b..5ef368f4 100644
--- a/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt
+++ b/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt
@@ -16,17 +16,27 @@ class ProviderConfigFormTest {
@Test
fun catalogIncludesEveryPiBuiltInProviderAndCustomEndpoint() {
assertEquals(35, PiProviderCatalog.builtInProviders.size)
- assertEquals(36, PiProviderCatalog.providers.size)
+ assertEquals(37, PiProviderCatalog.providers.size)
val customProviders = PiProviderCatalog.providers.filterNot { it.isBuiltIn }
- assertEquals(1, customProviders.size)
- assertEquals("openai-compatible", customProviders.single().id)
+ assertEquals(listOf("openai-compatible", "requesty"), customProviders.map { it.id })
assertEquals(
listOf("openai", "openai-compatible"),
PiProviderCatalog.providers.take(2).map { it.id },
)
}
+ @Test
+ fun requestyPrefillsItsOpenAiCompatibleEndpoint() {
+ val state = ProviderFormState.fromConfig(null)
+
+ state.applyProviderDefaults(PiProviderCatalog.resolve("requesty"))
+
+ assertEquals("https://router.requesty.ai/v1", state.baseUrl)
+ assertTrue(state.selectedDefinition.requiresBaseUrl)
+ assertFalse(state.buildConfig().compatibilityMode)
+ }
+
@Test
fun compatibilityModeIsScopedToOpenAiCompatibleProvider() {
val state = ProviderFormState.fromConfig(null)
diff --git a/shared/src/commonMain/composeResources/drawable/provider_requesty.xml b/shared/src/commonMain/composeResources/drawable/provider_requesty.xml
new file mode 100644
index 00000000..6e81ccf3
--- /dev/null
+++ b/shared/src/commonMain/composeResources/drawable/provider_requesty.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt
index 7427eda0..2659d1cf 100644
--- a/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt
+++ b/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt
@@ -83,6 +83,7 @@ object PiProviderCatalog {
builtin("huggingface", "Hugging Face", "https://router.huggingface.co/v1", "MiniMaxAI/MiniMax-M2.7", category = "Aggregators"),
builtin("together", "Together", "https://api.together.ai/v1", "Qwen/Qwen3.5-397B-A17B", category = "Aggregators"),
builtin("fireworks", "Fireworks", "https://api.fireworks.ai/inference", "accounts/fireworks/models/deepseek-v4-flash", category = "Aggregators"),
+ custom("requesty", "Requesty", "https://router.requesty.ai/v1", "openai/gpt-5.4", category = "Aggregators"),
builtin("opencode", "OpenCode Zen", "", "big-pickle", category = "Coding"),
builtin("opencode-go", "OpenCode Zen Go", "", "deepseek-v4-flash", category = "Coding"),
builtin("kimi-coding", "Kimi For Coding", "https://api.kimi.com/coding", "k2p7", category = "Coding"),
@@ -169,15 +170,18 @@ private fun builtin(
private fun custom(
id: String,
displayName: String,
+ defaultBaseUrl: String = DefaultCustomProviderBaseUrl,
+ defaultModelId: String = DefaultCustomModelId,
+ category: String = "Custom",
): PiProviderDefinition = PiProviderDefinition(
id = id,
displayName = displayName,
- defaultBaseUrl = DefaultCustomProviderBaseUrl,
- defaultModelId = DefaultCustomModelId,
+ defaultBaseUrl = defaultBaseUrl,
+ defaultModelId = defaultModelId,
supportsApiKey = true,
requiresBaseUrl = true,
isBuiltIn = false,
- category = "Custom",
+ category = category,
)
private fun hostOf(baseUrl: String): String {
diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt
index 2076e2db..5c651fc9 100644
--- a/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt
+++ b/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt
@@ -164,7 +164,11 @@ class SharedProviderModelCatalogClient(engine: HttpClientEngine? = null) {
config: LlmProviderConfig,
): SharedProviderModelsResult {
val definition = PiProviderCatalog.resolve(config.piProviderId)
- val providerModels = fetchProviderModels(config)
+ val providerModels = if (definition.id == "requesty") {
+ fetchRequestyModels(config)
+ } else {
+ fetchProviderModels(config)
+ }
if (providerModels.models.isNotEmpty()) return providerModels
val publicModels = fetchPublicProviderModels(definition)
@@ -223,9 +227,23 @@ class SharedProviderModelCatalogClient(engine: HttpClientEngine? = null) {
}
}
- private suspend fun fetchProviderModels(config: LlmProviderConfig): SharedProviderModelsResult {
+ // Requesty lists its curated managed models first, followed by the full catalog.
+ private suspend fun fetchRequestyModels(config: LlmProviderConfig): SharedProviderModelsResult {
+ val managedModels = fetchProviderModels(config, endpointSuffix = "/managed")
+ val catalogModels = fetchProviderModels(config)
+ val models = (managedModels.models + catalogModels.models).distinctBy(String::lowercase)
+ return SharedProviderModelsResult(
+ models,
+ if (models.isEmpty()) catalogModels.error ?: managedModels.error else null,
+ )
+ }
+
+ private suspend fun fetchProviderModels(
+ config: LlmProviderConfig,
+ endpointSuffix: String = "",
+ ): SharedProviderModelsResult {
return runCatching {
- val modelsUrl = modelsEndpoint(config.baseUrl)
+ val modelsUrl = modelsEndpoint(config.baseUrl) + endpointSuffix
val response = client.get(modelsUrl) {
headers {
append(HttpHeaders.Authorization, "Bearer ${config.apiKey.trim()}")
diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt
index e16cb245..46a6a71a 100644
--- a/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt
+++ b/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt
@@ -84,6 +84,7 @@ internal fun providerBrandIconRes(providerId: String): DrawableResource = when (
"opencode" -> Res.drawable.provider_opencode
"opencode-go" -> Res.drawable.provider_opencode_go
"openrouter" -> Res.drawable.provider_openrouter
+ "requesty" -> Res.drawable.provider_requesty
"together" -> Res.drawable.provider_together
"vercel-ai-gateway" -> Res.drawable.provider_vercel_ai_gateway
"xai" -> Res.drawable.provider_xai
diff --git a/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt b/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt
index 1c6c2f45..5dcc5257 100644
--- a/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt
+++ b/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt
@@ -55,6 +55,33 @@ class SharedProviderModelCatalogClientTest {
assertNull(result.error)
}
+ @Test
+ fun requestyListsManagedModelsBeforeFullCatalog() = runTest {
+ val requestedUrls = mutableListOf()
+ val engine = MockEngine { request ->
+ requestedUrls += request.url.toString()
+ assertEquals("Bearer secret", request.headers[HttpHeaders.Authorization])
+ val body = when (request.url.encodedPath) {
+ "/v1/models/managed" -> """{"data":[{"id":"claude-sonnet-4-5"}]}"""
+ else -> """{"data":[{"id":"openai/gpt-4o-mini"},{"id":"claude-sonnet-4-5"}]}"""
+ }
+ respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json"))
+ }
+ val result = SharedProviderModelCatalogClient(engine).fetchModels(
+ customConfig(
+ piProviderId = "requesty",
+ baseUrl = "https://router.requesty.ai/v1",
+ ),
+ )
+
+ assertEquals(
+ listOf("https://router.requesty.ai/v1/models/managed", "https://router.requesty.ai/v1/models"),
+ requestedUrls,
+ )
+ assertEquals(listOf("claude-sonnet-4-5", "openai/gpt-4o-mini"), result.models)
+ assertNull(result.error)
+ }
+
@Test
fun modelsDevProviderAliasesMatchAetherBuiltIns() {
assertEquals(
diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt
index 3f9cd0c3..b093d4fd 100644
--- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt
+++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt
@@ -7954,6 +7954,7 @@ private fun List.upsertSharedTool(
internal fun LlmProviderConfig.supportsSharedVisibleReasoningTrace(): Boolean =
baseUrl.contains("deepseek", ignoreCase = true) ||
baseUrl.contains("openrouter", ignoreCase = true) ||
+ baseUrl.contains("requesty", ignoreCase = true) ||
modelId.contains("deepseek", ignoreCase = true) ||
modelId.contains("openrouter", ignoreCase = true)