From 1cd84aa276d50c591279a680a8fb5fb98052b905 Mon Sep 17 00:00:00 2001 From: David Gonzalez Date: Wed, 9 Sep 2026 15:08:52 +0200 Subject: [PATCH 1/2] Add updatedPickers feature flag Gates the upcoming model and reasoning picker changes behind a single toggle, since both pickers move to availability-based grouping, backend driven ordering and tier-dependent headers together. Defaults to false so it is opt-in from the internal feature flag menu until the work is ready for wider testing. https://app.asana.com/1/137249556945/task/1218299205152314 Co-Authored-By: Claude Opus 5 --- .../duckduckgo/duckchat/impl/feature/DuckChatFeature.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 } From 8c59f4fa93159de5e4a09812a33ca07c3df85fbe Mon Sep 17 00:00:00 2001 From: David Gonzalez Date: Wed, 9 Sep 2026 15:59:54 +0200 Subject: [PATCH 2/2] Send auth token when fetching Duck.ai models The models endpoint only returns the editorial label used for picker sublines when the request is authenticated, so pass the subscription access token as a bearer header. The header is gated behind updatedPickers, keeping the request anonymous until the new pickers consume the labels, and a failed token lookup falls back to an unauthenticated fetch rather than losing the model list. Re-fetch now also keys off subscription status, not just entitlements: signing in or out changes whether labels come back, while entitlements stay empty either way when there is no subscription. https://app.asana.com/1/137249556945/task/1218299204283397 Co-Authored-By: Claude Opus 5 --- .../impl/models/DuckAiModelManager.kt | 27 ++- .../impl/models/DuckAiModelsService.kt | 6 +- .../impl/models/RealDuckAiModelManagerTest.kt | 206 +++++++++++++----- 3 files changed, 177 insertions(+), 62 deletions(-) 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"),