diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index 23916b9441df..4d3cbc5aec54 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -297,4 +297,13 @@ interface DuckChatFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun sendDuckAiSessionWideEvent(): Toggle + + /** + * @return `true` when the updated Duck.ai model and reasoning pickers are enabled: models grouped + * by availability, backend-driven ordering and sublines, and a gated section header that follows the + * user's tier. + * If the remote feature is not present defaults to `false`. + */ + @Toggle.DefaultValue(DefaultFeatureValue.FALSE) + fun updatedPickers(): Toggle } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelManager.kt index 645d23b62a33..bc8c086cc8e5 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelManager.kt @@ -20,16 +20,19 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckAiHostProvider +import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.store.DuckChatDataStore import com.duckduckgo.duckchat.impl.store.SelectedModel import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.Subscriptions import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -86,6 +89,7 @@ class RealDuckAiModelManager @Inject constructor( private val dataStore: DuckChatDataStore, private val subscriptions: Subscriptions, private val duckAiHostProvider: DuckAiHostProvider, + private val duckChatFeature: Lazy, private val dispatcherProvider: DispatcherProvider, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : DuckAiModelManager { @@ -113,10 +117,15 @@ class RealDuckAiModelManager @Inject constructor( } catch (e: Exception) { logcat { "Duck.ai Model Manager: failed to restore cached selection: ${e.message}" } } - subscriptions.getEntitlements() + // Status as well as entitlements: signing in or out changes whether the response carries + // model labels, and entitlements stay empty either way when there is no subscription. + combine( + subscriptions.getEntitlements(), + subscriptions.getSubscriptionStatusFlow(), + ) { entitlements, status -> entitlements to status } .distinctUntilChanged() .collect { - logcat { "Duck.ai Model Manager: entitlements changed, re-fetching models" } + logcat { "Duck.ai Model Manager: subscription state changed, re-fetching models" } fetchModels() } } @@ -205,7 +214,19 @@ class RealDuckAiModelManager @Inject constructor( private suspend fun fetchModelsResponse(): AIChatModelsResponse { val url = DuckAiModelsService.modelsUrl(duckAiHostProvider.getHost()) - return modelsService.getModels(url) + return modelsService.getModels(url, authorizationHeader()) + } + + // The picker sublines (model `label`) are only returned on an authenticated request, so the + // token rides along only while the updated pickers are the ones consuming it. + private suspend fun authorizationHeader(): String? { + if (!duckChatFeature.get().updatedPickers().isEnabled()) return null + return runCatching { + subscriptions.getAccessToken()?.takeUnless { it.isBlank() }?.let { "Bearer $it" } + }.getOrElse { + logcat { "Duck.ai Model Manager: failed to resolve access token, fetching models unauthenticated: ${it.message}" } + null + } } /** diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelsService.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelsService.kt index 589d6fdffa88..c722ed20c351 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelsService.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/models/DuckAiModelsService.kt @@ -19,12 +19,16 @@ package com.duckduckgo.duckchat.impl.models import com.duckduckgo.anvil.annotations.ContributesServiceApi import com.duckduckgo.di.scopes.AppScope import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Url @ContributesServiceApi(AppScope::class) interface DuckAiModelsService { @GET - suspend fun getModels(@Url url: String): AIChatModelsResponse + suspend fun getModels( + @Url url: String, + @Header("Authorization") authorization: String?, + ): AIChatModelsResponse companion object { private const val MODELS_PATH = "/duckchat/v1/models" diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/models/RealDuckAiModelManagerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/models/RealDuckAiModelManagerTest.kt index b333dd9fd291..4b8059f5ef5f 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/models/RealDuckAiModelManagerTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/models/RealDuckAiModelManagerTest.kt @@ -18,13 +18,17 @@ package com.duckduckgo.duckchat.impl.models import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckAiHostProvider +import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.store.DuckChatDataStore import com.duckduckgo.duckchat.impl.store.SelectedModel +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.api.model.Entitlement import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -40,6 +44,7 @@ import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.stub +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -56,12 +61,15 @@ class RealDuckAiModelManagerTest { private val duckAiHostProvider: DuckAiHostProvider = mock() private val entitlementFlow = MutableSharedFlow>() + private val subscriptionStatusFlow = MutableStateFlow(SubscriptionStatus.UNKNOWN) + private val duckChatFeature = FakeFeatureToggleFactory.create(DuckChatFeature::class.java) private lateinit var testee: RealDuckAiModelManager @Before fun setUp() { whenever(subscriptions.getEntitlements()).thenReturn(entitlementFlow) + whenever(subscriptions.getSubscriptionStatusFlow()).thenReturn(subscriptionStatusFlow) whenever(duckAiHostProvider.getHost()).thenReturn("duck.ai") subscriptions.stub { onBlocking { isEligible() }.thenReturn(true) } dataStore.stub { onBlocking { hasClearedPinnedDefaultModel() }.thenReturn(true) } @@ -73,6 +81,7 @@ class RealDuckAiModelManagerTest { dataStore = dataStore, subscriptions = subscriptions, duckAiHostProvider = duckAiHostProvider, + duckChatFeature = { duckChatFeature }, dispatcherProvider = coroutineRule.testDispatcherProvider, appCoroutineScope = coroutineRule.testScope, ) @@ -138,7 +147,7 @@ class RealDuckAiModelManagerTest { fun whenFetchModelsThenModelsResolvedAndStateUpdated() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("id1", accessTier = listOf("free"), entityHasAccess = true), @@ -161,7 +170,7 @@ class RealDuckAiModelManagerTest { fun whenEmptyAccessTierThenFallsBackToEntityHasAccess() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = emptyList(), entityHasAccess = true)), ), @@ -178,7 +187,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = listOf("plus", "pro"), entityHasAccess = false)), ), @@ -194,7 +203,7 @@ class RealDuckAiModelManagerTest { fun whenNonEmptyAccessTierAndTierDoesNotMatchThenNotAccessibleDespiteEntityHasAccess() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = listOf("plus", "pro"), entityHasAccess = true)), ), @@ -210,7 +219,7 @@ class RealDuckAiModelManagerTest { fun whenDisplayNameNullThenFallsBackToName() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", displayName = null, shortName = null)), ), @@ -228,7 +237,7 @@ class RealDuckAiModelManagerTest { fun whenNoSelectionPersistedThenFirstAccessibleModelSelected() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("id1", accessTier = listOf("plus"), entityHasAccess = false), @@ -248,7 +257,7 @@ class RealDuckAiModelManagerTest { fun whenNoSelectionPersistedAndListReorderedThenDefaultFollowsNewFirstAccessible() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("id1", accessTier = listOf("free"), entityHasAccess = true), @@ -262,7 +271,7 @@ class RealDuckAiModelManagerTest { assertEquals("id1", testee.modelState.value.selectedModelId) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("id2", accessTier = listOf("free"), entityHasAccess = true), @@ -281,7 +290,7 @@ class RealDuckAiModelManagerTest { fun whenUserPickedModelAndListReorderedThenPickPreserved() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("id2", "model2")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("id1", accessTier = listOf("free"), entityHasAccess = true), @@ -302,7 +311,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("id", "model")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = listOf("plus"), entityHasAccess = true)), ), @@ -319,7 +328,7 @@ class RealDuckAiModelManagerTest { fun whenSelectedModelNoLongerAccessibleThenFallsBackToFirstAccessible() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("id1", "model1")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("id1", accessTier = listOf("plus"), entityHasAccess = false), @@ -339,7 +348,7 @@ class RealDuckAiModelManagerTest { fun whenSelectedModelRemovedThenFallsBackToFirstAccessible() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("removed", "removed")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true)), ), @@ -356,7 +365,7 @@ class RealDuckAiModelManagerTest { fun whenNoAccessibleModelsThenSelectedModelIdIsNull() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = listOf("plus"), entityHasAccess = false)), ), @@ -372,7 +381,7 @@ class RealDuckAiModelManagerTest { fun whenFetchModelsFailsThenStateUnchanged() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenThrow(RuntimeException("Network error")) + whenever(modelsService.getModels(any(), anyOrNull())).thenThrow(RuntimeException("Network error")) testee = createManager() testee.fetchModels() @@ -416,7 +425,7 @@ class RealDuckAiModelManagerTest { fun whenSubscriptionInactiveThenTierIsFree() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -429,7 +438,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -442,7 +451,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "pro", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -454,7 +463,7 @@ class RealDuckAiModelManagerTest { fun whenModelHasEmptyAccessTierAndEntityHasNoAccessThenModelIsFilteredOut() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("visible", accessTier = listOf("free"), entityHasAccess = true), @@ -475,7 +484,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) whenever(subscriptions.isEligible()).thenReturn(false) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("free", accessTier = listOf("free"), entityHasAccess = true), @@ -496,7 +505,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) whenever(subscriptions.isEligible()).thenReturn(true) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("free", accessTier = listOf("free"), entityHasAccess = true), @@ -520,7 +529,7 @@ class RealDuckAiModelManagerTest { whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) whenever(subscriptions.isEligible()).thenReturn(false) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("free", accessTier = listOf("free", "plus", "pro"), entityHasAccess = true), @@ -542,7 +551,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) whenever(subscriptions.isEligible()).thenThrow(RuntimeException("Error")) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("free", accessTier = listOf("free"), entityHasAccess = true), @@ -563,7 +572,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "subscriber", product = "Network Protection")))) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -576,7 +585,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "premium", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -589,7 +598,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(emptySet())) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -609,7 +618,7 @@ class RealDuckAiModelManagerTest { ), ), ) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -621,7 +630,7 @@ class RealDuckAiModelManagerTest { fun whenSubscriptionExpiredThenTierIsFree() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.EXPIRED) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -634,7 +643,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.GRACE_PERIOD) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -646,7 +655,7 @@ class RealDuckAiModelManagerTest { fun whenSubscriptionStatusThrowsThenTierDefaultsToFree() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenThrow(RuntimeException("Error")) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() testee.fetchModels() @@ -658,7 +667,7 @@ class RealDuckAiModelManagerTest { fun whenFetchModelsThenProviderResolvedFromRemoteFields() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("gpt-5-mini", provider = "openai"), @@ -683,11 +692,92 @@ class RealDuckAiModelManagerTest { assertEquals(ModelProvider.UNKNOWN, byId.getValue("some-other-model").provider) } + @Test + fun whenAccessTokenExistsThenModelsFetchedWithBearerHeader() = runTest { + whenever(dataStore.getSelectedModel()).thenReturn(null) + whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) + duckChatFeature.updatedPickers().setRawStoredState(Toggle.State(enable = true)) + whenever(subscriptions.getAccessToken()).thenReturn("token123") + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( + AIChatModelsResponse(listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true))), + ) + + testee = createManager() + testee.fetchModels() + + verify(modelsService).getModels("https://duck.ai/duckchat/v1/models", "Bearer token123") + } + + @Test + fun whenNoAccessTokenThenModelsFetchedWithoutAuthorizationHeader() = runTest { + whenever(dataStore.getSelectedModel()).thenReturn(null) + whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) + duckChatFeature.updatedPickers().setRawStoredState(Toggle.State(enable = true)) + whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( + AIChatModelsResponse(listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true))), + ) + + testee = createManager() + testee.fetchModels() + + verify(modelsService).getModels("https://duck.ai/duckchat/v1/models", null) + } + + @Test + fun whenAccessTokenLookupFailsThenModelsStillFetchedUnauthenticated() = runTest { + whenever(dataStore.getSelectedModel()).thenReturn(null) + whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) + duckChatFeature.updatedPickers().setRawStoredState(Toggle.State(enable = true)) + subscriptions.stub { onBlocking { getAccessToken() }.thenThrow(RuntimeException("boom")) } + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( + AIChatModelsResponse(listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true))), + ) + + testee = createManager() + testee.fetchModels() + + verify(modelsService).getModels("https://duck.ai/duckchat/v1/models", null) + assertEquals(1, testee.modelState.value.models.size) + } + + @Test + fun whenUpdatedPickersDisabledThenAccessTokenNotSent() = runTest { + whenever(dataStore.getSelectedModel()).thenReturn(null) + whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) + duckChatFeature.updatedPickers().setRawStoredState(Toggle.State(enable = false)) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( + AIChatModelsResponse(listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true))), + ) + + testee = createManager() + testee.fetchModels() + + verify(modelsService).getModels("https://duck.ai/duckchat/v1/models", null) + verify(subscriptions, never()).getAccessToken() + } + + @Test + fun whenSubscriptionStatusChangesThenModelsRefetched() = runTest { + whenever(dataStore.getSelectedModel()).thenReturn(null) + whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( + AIChatModelsResponse(listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true))), + ) + + testee = createManager() + entitlementFlow.emit(emptySet()) + + subscriptionStatusFlow.value = SubscriptionStatus.AUTO_RENEWABLE + + verify(modelsService, times(2)).getModels(any(), anyOrNull()) + } + @Test fun whenEntitlementsChangeThenModelsFetched() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse(listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true))), ) @@ -702,7 +792,7 @@ class RealDuckAiModelManagerTest { fun whenModelHasSupportedFileTypesThenResolvedModel() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("claude", supportedFileTypes = listOf("application/pdf")), @@ -725,7 +815,7 @@ class RealDuckAiModelManagerTest { fun whenAttachmentLimitsProvidedThenResolvedForFreeTier() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( models = listOf(remoteModel("id")), attachmentLimits = mapOf( @@ -757,7 +847,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( models = listOf(remoteModel("id")), attachmentLimits = mapOf( @@ -787,7 +877,7 @@ class RealDuckAiModelManagerTest { fun whenNoAttachmentLimitsThenDefaultsUsed() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse(models = listOf(remoteModel("id"))), ) @@ -806,7 +896,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( models = listOf(remoteModel("id")), attachmentLimits = mapOf( @@ -830,7 +920,7 @@ class RealDuckAiModelManagerTest { fun whenModelSupportsImageUploadThenResolvedModelHasImageUploadEnabledAndNativeFormats() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", supportsImageUpload = true)), ), @@ -848,7 +938,7 @@ class RealDuckAiModelManagerTest { fun whenModelDoesNotSupportImageUploadThenResolvedModelHasImageUploadDisabledAndEmptyFormats() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", supportsImageUpload = false)), ), @@ -891,7 +981,7 @@ class RealDuckAiModelManagerTest { fun whenRemoteHasUnknownReasoningEffortThenUnknownDropped() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -915,7 +1005,7 @@ class RealDuckAiModelManagerTest { fun whenRemoteReasoningEffortAccessMissingThenDomainListIsEmpty() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", reasoningEffortAccess = null)), ), @@ -931,7 +1021,7 @@ class RealDuckAiModelManagerTest { fun whenRemoteReasoningEffortAccessHasUnknownIdThenUnknownDropped() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -957,7 +1047,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) whenever(subscriptions.getEntitlements()).thenReturn(flowOf(setOf(Entitlement(name = "plus", product = "Duck.ai")))) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -992,7 +1082,7 @@ class RealDuckAiModelManagerTest { // The "model takes precedence" rule lives at tap-handling time, not in the data layer. whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1052,12 +1142,12 @@ class RealDuckAiModelManagerTest { fun whenRestoreCachedSelectionThrowsThenEntitlementCollectorStillStarts() = runTest { whenever(dataStore.getSelectedModel()).thenThrow(RuntimeException("disk error")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn(AIChatModelsResponse(emptyList())) + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn(AIChatModelsResponse(emptyList())) testee = createManager() entitlementFlow.emit(emptySet()) - verify(modelsService).getModels(any()) + verify(modelsService).getModels(any(), anyOrNull()) } @Test @@ -1076,7 +1166,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(dataStore.getSelectedReasoningMode()).thenReturn(ReasoningMode.REASONING.rawValue) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1103,7 +1193,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(dataStore.getSelectedReasoningMode()).thenReturn(ReasoningMode.EXTENDED_REASONING.rawValue) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1130,7 +1220,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(dataStore.getSelectedReasoningMode()).thenReturn(ReasoningMode.REASONING.rawValue) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1204,7 +1294,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(dataStore.getSelectedReasoningMode()).thenReturn(ReasoningMode.REASONING.rawValue) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1229,7 +1319,7 @@ class RealDuckAiModelManagerTest { fun whenSelectReasoningModeAndUnsupportedThenIgnored() = runTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1280,7 +1370,7 @@ class RealDuckAiModelManagerTest { // fetchModels rebuilds modelState — must not silently drop in-session chat-scoped picks. whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf(remoteModel("id", accessTier = listOf("free"), entityHasAccess = true)), ), @@ -1300,7 +1390,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(dataStore.getSelectedReasoningMode()).thenReturn(ReasoningMode.FAST.rawValue) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1331,7 +1421,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedReasoningMode()).thenReturn(ReasoningMode.REASONING.rawValue) whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1356,7 +1446,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedReasoningMode()).thenReturn(null) whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("m", "M")) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel( @@ -1379,7 +1469,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(dataStore.getSelectedProvider()).thenReturn(ModelProvider.ANTHROPIC.name) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("gpt", provider = "openai"), @@ -1399,7 +1489,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(SelectedModel("gpt", "gpt")) whenever(dataStore.getSelectedProvider()).thenReturn(ModelProvider.ANTHROPIC.name) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("gpt", provider = "openai"), @@ -1419,7 +1509,7 @@ class RealDuckAiModelManagerTest { whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(dataStore.getSelectedProvider()).thenReturn(ModelProvider.MISTRAL.name) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse(listOf(remoteModel("gpt", provider = "openai"))), ) @@ -1435,7 +1525,7 @@ class RealDuckAiModelManagerTest { givenProviderStore() whenever(dataStore.getSelectedModel()).thenReturn(null) whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE) - whenever(modelsService.getModels(any())).thenReturn( + whenever(modelsService.getModels(any(), anyOrNull())).thenReturn( AIChatModelsResponse( listOf( remoteModel("gpt", provider = "openai"),