From 5f9426bc67a4414fb88e43a01b1c67851e90789d Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:07:12 -0700 Subject: [PATCH] refactor(core/providers): Unify LLM API access via OpenAI-compatible standard - Refactors core provider model factories (Anthropic, Gemini, etc.) to conform to a unified OpenAI-compatible interface for consistency and future proofing. - Implements explicit HTTP version handling (`HTTP_1_1` vs `HTTP_2`) within `ProviderSettings` and network builders. - Updates numerous shared UI components across the desktop application to utilize standardized loading spinners in `AppComponents`. --- .../io/askimo/ui/bookmarks/BookmarksView.kt | 3 +- .../io/askimo/ui/chat/ChatInputField.kt | 8 +- .../main/kotlin/io/askimo/ui/chat/ChatView.kt | 7 +- .../io/askimo/ui/chat/MessageComponents.kt | 2 +- .../ui/common/components/ActionInputField.kt | 13 +- .../askimo/ui/common/theme/AppComponents.kt | 38 +++ .../io/askimo/ui/common/ui/MarkdownText.kt | 3 +- .../ui/common/ui/MermaidChartRenderer.kt | 15 +- .../kotlin/io/askimo/ui/mcp/McpToolsDialog.kt | 6 +- .../io/askimo/ui/plan/PlanDetailView.kt | 13 +- .../io/askimo/ui/plan/PlansGalleryView.kt | 3 +- .../io/askimo/ui/session/SessionsView.kt | 3 +- .../ui/settings/AppearanceSettingsSection.kt | 24 +- .../io/askimo/ui/shell/GlobalSearchDialog.kt | 4 +- .../io/askimo/ui/shell/NavigationSidebar.kt | 11 +- .../kotlin/io/askimo/ui/shell/SplashScreen.kt | 8 +- .../main/resources/i18n/messages.properties | 2 + .../resources/i18n/messages_de.properties | 2 + .../resources/i18n/messages_es.properties | 2 + .../resources/i18n/messages_fr.properties | 2 + .../resources/i18n/messages_ja_JP.properties | 2 + .../resources/i18n/messages_ko_KR.properties | 2 + .../resources/i18n/messages_pt_BR.properties | 2 + .../resources/i18n/messages_vi_VN.properties | 2 + .../resources/i18n/messages_zh_CN.properties | 2 + .../resources/i18n/messages_zh_TW.properties | 2 + .../askimo/desktop/chat/ProjectSidePanel.kt | 7 +- .../io/askimo/desktop/chat/RagSourcesTree.kt | 3 +- .../io/askimo/desktop/plan/PlanEditorView.kt | 7 +- .../desktop/project/EditProjectDialog.kt | 3 +- .../io/askimo/desktop/project/ProjectView.kt | 7 +- .../io/askimo/desktop/project/ProjectsView.kt | 3 +- .../askimo/desktop/project/UrlInputDialog.kt | 7 +- .../settings/AIProviderSettingsSection.kt | 3 +- .../settings/AdvancedSettingsSection.kt | 4 +- .../settings/ProviderSelectionDialog.kt | 11 +- .../desktop/shell/ProviderModelPanel.kt | 20 +- .../askimo/core/providers/AiServiceBuilder.kt | 4 - .../askimo/core/providers/ProviderSettings.kt | 19 ++ .../anthropic/AnthropicModelFactory.kt | 68 ++--- .../providers/docker/DockerAiModelFactory.kt | 57 +--- .../core/providers/docker/DockerAiSettings.kt | 3 + .../providers/gemini/GeminiModelFactory.kt | 63 ++--- .../lmstudio/LmStudioModelFactory.kt | 23 +- .../providers/localai/LocalAiModelFactory.kt | 8 +- .../providers/ollama/OllamaModelFactory.kt | 8 +- .../providers/openai/OpenAiModelFactory.kt | 2 +- .../openaicompatible/OpenAiApiDelegate.kt | 264 ++++++++++++++++++ .../openaicompatible/OpenAiApiMode.kt | 15 - .../OpenAiCompatibleChatModelFactory.kt | 157 +++++------ .../OpenAiCompatibleModelFactory.kt | 139 +++------ .../OpenAiCompatibleSettings.kt | 28 +- .../core/providers/xai/XAiModelFactory.kt | 2 +- .../core/telemetry/TelemetryCollector.kt | 8 +- .../io/askimo/core/util/HttpClientUtils.kt | 43 +++ .../core/util/LoggingHttpClientBuilder.kt | 19 +- .../io/askimo/core/util/SystemPrompts.kt | 14 - 57 files changed, 699 insertions(+), 501 deletions(-) create mode 100644 shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiDelegate.kt rename shared/src/main/kotlin/io/askimo/core/providers/{ => openaicompatible}/OpenAiCompatibleChatModelFactory.kt (69%) create mode 100644 shared/src/main/kotlin/io/askimo/core/util/HttpClientUtils.kt delete mode 100644 shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt index 4d27b8276..d3e93a325 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/bookmarks/BookmarksView.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.BookmarkBorder -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -115,7 +114,7 @@ fun bookmarksView( modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() + AppComponents.loadingSpinner() } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt index 8722cf515..43cb55532 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt @@ -46,7 +46,6 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -1241,10 +1240,7 @@ private fun toolsIndicatorButton( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AppComponents.loadingSpinner(size = 24.dp) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource("chat.tools.popup.loading")) } @@ -1616,7 +1612,7 @@ private fun fileAttachmentItem( horizontalArrangement = Arrangement.spacedBy(Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + AppComponents.loadingSpinner(size = 14.dp) Text( text = stringResource("chat.attachment.preview.loading"), style = AppTextStyles.hint, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt index 7a87714f0..a4d668efd 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt @@ -39,7 +39,6 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -599,11 +598,7 @@ fun chatView( modifier = Modifier.size(18.dp), ) if (ragIndexingStatus == "inprogress") { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - color = statusColor, - strokeWidth = 2.dp, - ) + AppComponents.loadingSpinner(size = 14.dp, color = statusColor) } } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt index 663363f98..0855f7404 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/MessageComponents.kt @@ -1227,7 +1227,7 @@ private fun thinkingSection( Box( modifier = Modifier .fillMaxWidth() - .heightIn(max = 300.dp) + .heightIn(max = 200.dp) .verticalScroll(scrollState) .padding(start = Spacing.small, end = Spacing.medium, top = 2.dp, bottom = 2.dp), ) { diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/ActionInputField.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/ActionInputField.kt index 4555e3724..71418452d 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/ActionInputField.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/components/ActionInputField.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -110,10 +109,8 @@ internal fun actionInputField( .pointerHoverIcon(PointerIcon.Hand), ) { if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, + AppComponents.loadingSpinner( + size = 20.dp, trackColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.3f), ) } else { @@ -136,10 +133,8 @@ internal fun actionInputField( .pointerHoverIcon(PointerIcon.Hand), ) { if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, + AppComponents.loadingSpinner( + size = 14.dp, trackColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.3f), ) } else { diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt index e07727b26..55289e70a 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/theme/AppComponents.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -55,6 +56,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults import androidx.compose.material3.MenuItemColors @@ -849,6 +851,42 @@ object AppComponents { } } + /** + * Standardised small loading spinner for all UI contexts. + * + * Color defaults to [LocalContentColor] so it automatically matches its container: + * - inside a `primaryButton` → onPrimary + * - inside a `bannerCardColors` card → onSecondaryContainer + * - on a plain surface → onSurface + * + * Bakes in `strokeWidth = 2.dp` (lighter than M3 default of 4.dp) for inline use. + * + * @param size Spinner diameter. 16.dp when inline in a button, 18.dp standalone (default). + * @param color Override only when a dynamic/semantic color is needed (e.g. a status color). + */ + @Composable + fun loadingSpinner( + modifier: Modifier = Modifier, + size: Dp = 18.dp, + color: Color = LocalContentColor.current, + trackColor: Color? = null, + ) { + if (trackColor != null) { + CircularProgressIndicator( + modifier = modifier.size(size), + strokeWidth = 2.dp, + color = color, + trackColor = trackColor, + ) + } else { + CircularProgressIndicator( + modifier = modifier.size(size), + strokeWidth = 2.dp, + color = color, + ) + } + } + @Composable fun scrollbarStyle(): ScrollbarStyle = ScrollbarStyle( minimalHeight = 16.dp, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt index a7a67c4c2..ac3692756 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MarkdownText.kt @@ -599,8 +599,9 @@ private fun renderCodeBlock(codeBlock: FencedCodeBlock, viewportTopY: Float? = n contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator( + AppComponents.loadingSpinner( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), ) Spacer(modifier = Modifier.height(Spacing.small)) Text( diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MermaidChartRenderer.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MermaidChartRenderer.kt index f596b2314..cd3c4eb9d 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MermaidChartRenderer.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/ui/MermaidChartRenderer.kt @@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -387,9 +386,7 @@ fun mermaidChart( isMermaidCliAvailable == null -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onSurface, - ) + AppComponents.loadingSpinner() Spacer(modifier = Modifier.height(Spacing.large)) Text("Checking Mermaid CLI...", style = AppTextStyles.body) } @@ -417,9 +414,7 @@ fun mermaidChart( isLoading && isMermaidCliAvailable == true -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onSurface, - ) + AppComponents.loadingSpinner() Spacer(modifier = Modifier.height(Spacing.large)) Text(stringResource("mermaid.rendering.progress"), style = AppTextStyles.body) } @@ -707,11 +702,7 @@ private fun mermaidSetupInstructions( // Install progress spinner if (isInstalling) { Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) + AppComponents.loadingSpinner(size = 16.dp) Spacer(modifier = Modifier.width(Spacing.small)) Text( text = stringResource("mermaid.setup.installing"), diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpToolsDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpToolsDialog.kt index 41b53e466..b158c7aa6 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpToolsDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/mcp/McpToolsDialog.kt @@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -292,10 +291,7 @@ fun mcpToolsDialog( horizontalArrangement = Arrangement.spacedBy(Spacing.medium), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onSurface, - ) + AppComponents.loadingSpinner(size = 24.dp) Text( text = stringResource("mcp.tools.dialog.loading"), style = AppTextStyles.bodySecondary, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlanDetailView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlanDetailView.kt index 70bce27ef..ecb14ff84 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlanDetailView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlanDetailView.kt @@ -38,7 +38,6 @@ import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PushPin import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -383,11 +382,7 @@ fun planDetailView( enabled = !viewModel.isRunning, ) { if (viewModel.isRunning) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) + AppComponents.loadingSpinner(size = 16.dp) } else { Icon( Icons.Default.PlayArrow, @@ -668,11 +663,7 @@ private fun agenticStepRow( ) { Box(modifier = Modifier.padding(top = 2.dp)) { when (event) { - is PlanStepEvent.Started -> CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.secondary, - ) + is PlanStepEvent.Started -> AppComponents.loadingSpinner(size = 14.dp, color = MaterialTheme.colorScheme.secondary) is PlanStepEvent.WaitingForInput -> Icon( Icons.AutoMirrored.Filled.HelpOutline, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt index f6b76399f..972c8cee8 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansGalleryView.kt @@ -36,7 +36,6 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -186,7 +185,7 @@ fun plansGalleryView( Box( modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } + ) { AppComponents.loadingSpinner() } return@Column } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsView.kt index 58182ca35..13c5f1b03 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionsView.kt @@ -39,7 +39,6 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -192,7 +191,7 @@ fun sessionsView( when { viewModel.isLoading -> { Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + AppComponents.loadingSpinner() } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt index 2e54ae493..d8b03ed80 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt @@ -197,8 +197,20 @@ fun appearanceSettingsSection() { modifier = Modifier.padding(bottom = Spacing.small), ) - // Language & Font (formerly General settings) + // Language section + Spacer(modifier = Modifier.height(Spacing.small)) + Text( + text = stringResource("settings.language"), + style = AppTextStyles.sectionTitle, + ) languageSelectionCard() + + // Font section + Spacer(modifier = Modifier.height(Spacing.small)) + Text( + text = stringResource("settings.font"), + style = AppTextStyles.sectionTitle, + ) fontSettingsCard() // Theme Mode Section @@ -1258,7 +1270,7 @@ private fun languageSelectionCard() { ) { Text( text = stringResource("settings.app.language"), - style = AppTextStyles.fieldLabel, + style = AppTextStyles.body, modifier = Modifier.weight(1f).padding(end = Spacing.large), ) Box(modifier = Modifier.widthIn(min = dropdownRegularMinWidth, max = dropdownRegularMaxWidth)) { @@ -1359,7 +1371,7 @@ private fun preferredAIResponseLanguageField(availableLanguages: Map, currentSessionId: String?, inProgressSessionIds: Set, + bookmarkCountsBySession: Map = emptyMap(), onSelectProject: (String) -> Unit, onResumeSession: (String) -> Unit, onStarProject: (String, Boolean) -> Unit, @@ -745,6 +747,7 @@ private fun pinnedSection( session = session, isSelected = session.id == currentSessionId, isChatInProgress = session.id in inProgressSessionIds, + bookmarkCount = bookmarkCountsBySession[session.id] ?: 0, onResumeSession = onResumeSession, onUnpin = { onStarSession(session.id, false) }, onDelete = { onDeleteSession(session.id) }, @@ -845,6 +848,7 @@ private fun pinnedSessionItem( session: ChatSession, isSelected: Boolean, isChatInProgress: Boolean, + bookmarkCount: Int = 0, onResumeSession: (String) -> Unit, onUnpin: () -> Unit, onDelete: () -> Unit, @@ -878,6 +882,7 @@ private fun pinnedSessionItem( isSelected = isSelected, isChatInProgress = isChatInProgress, isHovered = isHovered || showMenu, + bookmarkCount = bookmarkCount, onResumeSession = onResumeSession, onMenuClick = { showMenu = true }, ) @@ -1125,6 +1130,7 @@ private fun sessionDrawerItemContent( text = session.title, onMenuClick = onMenuClick, isHovered = isHovered, + isSelected = isSelected, bookmarkCount = bookmarkCount, ) }, @@ -1144,9 +1150,11 @@ private fun navigationItemLabelWithMenu( text: String, onMenuClick: () -> Unit, isHovered: Boolean, + isSelected: Boolean = false, bookmarkCount: Int = 0, ) { val fontScale = LocalFontScale.current + val textColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -1155,6 +1163,7 @@ private fun navigationItemLabelWithMenu( Text( text = text, style = AppTextStyles.groupTitle, + color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), @@ -1170,7 +1179,7 @@ private fun navigationItemLabelWithMenu( Icon( Icons.Default.MoreVert, contentDescription = "More options", - tint = AppTextStyles.secondaryContent, + tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else AppTextStyles.secondaryContent, modifier = Modifier.size((18 * fontScale).dp), ) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SplashScreen.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SplashScreen.kt index 1b1dda451..516c4825d 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SplashScreen.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SplashScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,6 +22,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.unit.dp import io.askimo.core.VersionInfo +import io.askimo.ui.common.theme.AppComponents import io.askimo.ui.common.theme.AppTextStyles import io.askimo.ui.common.theme.Spacing import org.jetbrains.skia.Image @@ -77,11 +77,7 @@ fun splashScreen(isConnecting: Boolean = false) { horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(Spacing.small), ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AppComponents.loadingSpinner(size = 16.dp) Text( text = "Connecting…", style = AppTextStyles.caption, diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 0add24ace..1e6955d64 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=Show technical details event.shell.error.cause.hide=Hide technical details # Settings View +settings.language=Language +settings.font=Font settings.font.family.ui=UI Font Family settings.font.family.code=Code Font Family settings.font.size=Font Size diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 1d9ff9e8c..21b848d27 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=Technische Details anzeigen event.shell.error.cause.hide=Technische Details ausblenden # Settings View +settings.language=Sprache +settings.font=Schriftart settings.font.family.ui=UI-Schriftfamilie settings.font.family.code=Code-Schriftfamilie settings.font.size=Schriftgröße diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index c5e037a30..62188df18 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=Mostrar detalles técnicos event.shell.error.cause.hide=Ocultar detalles técnicos # Settings View +settings.language=Idioma +settings.font=Fuente settings.font.family.ui=Familia de Tipografía UI settings.font.family.code=Familia de Tipografía Código settings.font.size=Tamaño de fuente diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 2e6bdfc9a..e49b26f45 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=Afficher les détails techniques event.shell.error.cause.hide=Masquer les détails techniques # Settings View +settings.language=Langue +settings.font=Police settings.font.family.ui=Famille de police UI settings.font.family.code=Famille de police Code settings.font.size=Taille de police diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index 48f7232fb..2de09fed7 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=技術的な詳細を表示 event.shell.error.cause.hide=技術的な詳細を非表示 # Settings View +settings.language=言語 +settings.font=フォント settings.font.family.ui=UI フォントファミリー settings.font.family.code=コード フォントファミリー settings.font.size=フォントサイズ diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index 938933517..a51eeee3e 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=기술 세부 정보 표시 event.shell.error.cause.hide=기술 세부 정보 숨기기 # Settings View +settings.language=언어 +settings.font=글꼴 settings.font.family.ui=UI 폰트 패밀리 settings.font.family.code=코드 폰트 패밀리 settings.font.size=글꼴 크기 diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index 37a315643..b44091159 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=Mostrar detalhes técnicos event.shell.error.cause.hide=Ocultar detalhes técnicos # Settings View +settings.language=Idioma +settings.font=Fonte settings.font.family.ui=Família de Fonte UI settings.font.family.code=Família de Fonte Código settings.font.size=Tamanho da Fonte diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index b2fdc53be..66d25264e 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=Hiển thị chi tiết kỹ thuật event.shell.error.cause.hide=Ẩn chi tiết kỹ thuật # Settings View +settings.language=Idioma +settings.font=Fonte settings.font.family.ui=Family Font UI settings.font.family.code=Family Font Code settings.font.size=Cỡ chữ diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index 7b1dc1001..09cebdc62 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=显示技术详细信息 event.shell.error.cause.hide=隐藏技术详细信息 # Settings View +settings.language=语言 +settings.font=字体 settings.font.family.ui=UI 字体系列 settings.font.family.code=代码字体系列 settings.font.size=字号 diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index a8bf60dfe..cdcbdd3eb 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -769,6 +769,8 @@ event.shell.error.cause.show=顯示技術詳細資訊 event.shell.error.cause.hide=隱藏技術詳細資訊 # Settings View +settings.language=語言 +settings.font=字體 settings.font.family.ui=UI 字體系列 settings.font.family.code=代碼字體系列 settings.font.size=字型大小 diff --git a/desktop/src/main/kotlin/io/askimo/desktop/chat/ProjectSidePanel.kt b/desktop/src/main/kotlin/io/askimo/desktop/chat/ProjectSidePanel.kt index d9ba0eb2e..1992116b5 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/chat/ProjectSidePanel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/chat/ProjectSidePanel.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -174,11 +173,7 @@ fun communityProjectSidePanel( } themedTooltip(text = statusTooltip) { if (ragIndexingStatus in listOf("inprogress", "started", "queued")) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AppComponents.loadingSpinner(size = 14.dp) } else { Icon( imageVector = Icons.Default.AutoAwesome, diff --git a/desktop/src/main/kotlin/io/askimo/desktop/chat/RagSourcesTree.kt b/desktop/src/main/kotlin/io/askimo/desktop/chat/RagSourcesTree.kt index 63e7dc141..333248c28 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/chat/RagSourcesTree.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/chat/RagSourcesTree.kt @@ -38,7 +38,6 @@ import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -237,7 +236,7 @@ fun ragSourcesTree( horizontalArrangement = Arrangement.spacedBy(Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + AppComponents.loadingSpinner(size = 14.dp) Text( text = stringResource("rag.tree.search.indexing"), style = AppTextStyles.caption, diff --git a/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt b/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt index 15c3024ae..90bded044 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/plan/PlanEditorView.kt @@ -23,7 +23,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -129,11 +128,7 @@ fun planEditorView( enabled = viewModel.editorValidationError == null && !viewModel.isSaving, ) { if (viewModel.isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) + AppComponents.loadingSpinner(size = 16.dp) } else { Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp)) } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/project/EditProjectDialog.kt b/desktop/src/main/kotlin/io/askimo/desktop/project/EditProjectDialog.kt index 80b32f00b..e62e01f94 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/project/EditProjectDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/project/EditProjectDialog.kt @@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Error -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -120,7 +119,7 @@ fun editProjectDialog( .padding(48.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() + AppComponents.loadingSpinner() } }, actions = null, diff --git a/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt b/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt index 93abad4dc..f2fb44a08 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectView.kt @@ -41,7 +41,6 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -906,11 +905,7 @@ private fun indexProgressIndicator( horizontalArrangement = Arrangement.spacedBy(Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AppComponents.loadingSpinner(size = 14.dp) Text( text = stringResource("project.indexing.queued", indexProgress.blockedByName!!), style = AppTextStyles.caption, diff --git a/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectsView.kt b/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectsView.kt index 5967d9de2..1606fb50d 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectsView.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/project/ProjectsView.kt @@ -39,7 +39,6 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -173,7 +172,7 @@ fun projectsView( when { viewModel.isLoading -> { Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + AppComponents.loadingSpinner() } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/project/UrlInputDialog.kt b/desktop/src/main/kotlin/io/askimo/desktop/project/UrlInputDialog.kt index 13d7efbd0..0edd6251e 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/project/UrlInputDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/project/UrlInputDialog.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -182,11 +181,7 @@ fun urlInputDialog( enabled = !isValidating, ) { if (isValidating) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp), - color = MaterialTheme.colorScheme.onSurface, - strokeWidth = 2.dp, - ) + AppComponents.loadingSpinner(size = 16.dp) Spacer(Modifier.width(8.dp)) } Text( diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt index edb70462b..b35e55133 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt @@ -32,7 +32,6 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -568,7 +567,7 @@ private fun providerModelTypePickerDialog( when { isLoading -> { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.onSurfaceVariant) + AppComponents.loadingSpinner() Text(text = stringResource("settings.model.loading"), modifier = Modifier.padding(start = Spacing.large)) } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt index 05175f99e..dbac67550 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt @@ -181,7 +181,9 @@ private fun logLevelCard() { modifier = Modifier .fillMaxWidth() .clickableCard { logLevelDropdownExpanded = true }, - colors = CardDefaults.cardColors(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) { Row( modifier = Modifier diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt index b80db25e4..1fa68e567 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/ProviderSelectionDialog.kt @@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -219,7 +218,7 @@ fun providerWizardDialog(viewModel: ProviderWizardViewModel) { enabled = viewModel.connectionTestSuccess && !viewModel.isFetchingModelsForConfig, ) { if (viewModel.isFetchingModelsForConfig) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.surface) + AppComponents.loadingSpinner(size = 16.dp) Spacer(Modifier.width(Spacing.small)) } Text(stringResource("action.next")) @@ -231,7 +230,7 @@ fun providerWizardDialog(viewModel: ProviderWizardViewModel) { enabled = !viewModel.isTestingConnection && !viewModel.isFetchingModelsForConfig, ) { if (viewModel.isTestingConnection) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.surface) + AppComponents.loadingSpinner(size = 16.dp) Spacer(Modifier.width(Spacing.small)) } Text(stringResource("settings.save")) @@ -652,11 +651,11 @@ private fun instanceConfigScreen(viewModel: ProviderWizardViewModel) { field.options.forEach { option -> if (currentValue == option.value) { primaryButton(onClick = {}) { - Text(option.label, style = AppTextStyles.caption) + Text(option.label) } } else { secondaryButton(onClick = { viewModel.updateProviderField(field.name, option.value) }) { - Text(option.label, style = AppTextStyles.caption) + Text(option.label) } } } @@ -728,7 +727,7 @@ private fun modelPickerScreen( when { viewModel.isLoadingModels -> { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.onSurface) + AppComponents.loadingSpinner() Text(text = stringResource("settings.model.loading"), modifier = Modifier.padding(start = Spacing.large)) } } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt index 3b7d22b43..c6e187117 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/ProviderModelPanel.kt @@ -40,7 +40,6 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -191,9 +190,10 @@ internal fun providerModelPanel( ) } } else { - val sortedInstances = remember(state.availableInstances, currentInstanceId) { + val initialActiveInstanceId = remember { currentInstanceId } + val sortedInstances = remember(state.availableInstances) { state.availableInstances.sortedWith( - compareByDescending { it.id == currentInstanceId } + compareByDescending { it.id == initialActiveInstanceId } .thenBy { it.displayName.lowercase() }, ) } @@ -367,11 +367,7 @@ private fun modelListColumn( horizontalArrangement = Arrangement.spacedBy(Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) + AppComponents.loadingSpinner(size = 16.dp) Text( text = stringResource("settings.model.loading"), style = AppTextStyles.caption, @@ -718,11 +714,11 @@ private fun instanceEditForm( field.options.forEach { option -> if (currentValue == option.value) { primaryButton(onClick = {}) { - Text(option.label, style = AppTextStyles.body) + Text(option.label) } } else { secondaryButton(onClick = { state.updateEditField(field.name, option.value) }) { - Text(option.label, style = AppTextStyles.body) + Text(option.label) } } } @@ -777,13 +773,13 @@ private fun instanceEditForm( horizontalArrangement = Arrangement.spacedBy(Spacing.small, Alignment.End), ) { secondaryButton(onClick = onCancel, enabled = !state.isTestingEdit) { - Text(stringResource("settings.cancel"), style = AppTextStyles.caption) + Text(stringResource("settings.cancel")) } primaryButton( onClick = onSave, enabled = !state.isTestingEdit && state.editDisplayNameError == null, ) { - Text(stringResource("settings.save"), style = AppTextStyles.caption) + Text(stringResource("settings.save")) } } } diff --git a/shared/src/main/kotlin/io/askimo/core/providers/AiServiceBuilder.kt b/shared/src/main/kotlin/io/askimo/core/providers/AiServiceBuilder.kt index 0b9729960..7dd8c92e7 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/AiServiceBuilder.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/AiServiceBuilder.kt @@ -15,7 +15,6 @@ import dev.langchain4j.service.tool.ToolProvider import io.askimo.core.config.AppConfig import io.askimo.core.logging.logger import io.askimo.core.rag.MetadataAwareContentInjector -import io.askimo.core.util.SystemPrompts.systemMessage /** * Shared builder for creating ChatClient instances across all provider model factories. @@ -68,9 +67,6 @@ object AiServiceBuilder { } } .hallucinatedToolNameStrategy(ProviderModelUtils::hallucinatedToolHandler) - .systemMessageProvider { - systemMessage() - } .chatRequestTransformer { chatRequest, memoryId -> ChatRequestTransformers.addCustomSystemMessagesAndRemoveDuplicates( sessionId, diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ProviderSettings.kt b/shared/src/main/kotlin/io/askimo/core/providers/ProviderSettings.kt index 2f3d23d75..646ca2bfb 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ProviderSettings.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ProviderSettings.kt @@ -17,6 +17,18 @@ import io.askimo.core.providers.openai.OpenAiSettings import io.askimo.core.providers.openaicompatible.OpenAiCompatibleSettings import io.askimo.core.providers.xai.XAiSettings +/** + * HTTP protocol version used for connections to a provider endpoint. + * + * - [HTTP_1_1] — safe default for self-hosted servers (uvicorn, vLLM, FastAPI, Docker AI) + * that have incomplete or no HTTP/2 support. + * - [HTTP_2] — for cloud endpoints and local servers that fully support HTTP/2 multiplexing. + */ +enum class HttpVersion { + HTTP_1_1, + HTTP_2, +} + /** * Marker interface for model provider-specific configuration settings. * @@ -40,6 +52,13 @@ import io.askimo.core.providers.xai.XAiSettings interface ProviderSettings { val defaultModel: String + /** + * HTTP protocol version for connections to this provider's endpoint. + * Defaults to [HttpVersion.HTTP_2]. Override in settings classes that target servers + * without full HTTP/2 support (e.g. [DockerAiSettings]). + */ + val httpVersion: HttpVersion get() = HttpVersion.HTTP_2 + /** * Per-instance override for the utility/secondary model. * Blank means "use the provider-type default from AppConfig.models". diff --git a/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt index c7994910f..8ae4f779b 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt @@ -5,8 +5,6 @@ package io.askimo.core.providers.anthropic import dev.langchain4j.data.message.UserMessage -import dev.langchain4j.http.client.jdk.JdkHttpClient -import dev.langchain4j.http.client.jdk.JdkHttpClientBuilder import dev.langchain4j.memory.ChatMemory import dev.langchain4j.model.anthropic.AnthropicChatModel import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel @@ -32,10 +30,9 @@ import io.askimo.core.providers.ModelProvider.ANTHROPIC import io.askimo.core.providers.sendStreamingMessageWithCallback import io.askimo.core.telemetry.TelemetryChatModelListener import io.askimo.core.util.ApiKeyUtils.safeApiKey -import io.askimo.core.util.ProxyUtil import io.askimo.core.util.appJson +import io.askimo.core.util.createJdkHttpClientBuilder import io.askimo.core.util.httpGet -import io.askimo.core.util.withLoggingIfDebug import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -44,7 +41,6 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.net.http.HttpClient import java.time.Duration import kotlin.collections.mapNotNull import kotlin.collections.orEmpty @@ -72,13 +68,9 @@ class AnthropicModelFactory : ChatModelFactory { executionMode: ExecutionMode, chatMemory: ChatMemory?, ): ChatClient { - // Configure HTTP client for thinking probe (probe needs its own builder) - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) - // Probe thinking support once — result is persisted in ModelCapabilitiesCache if (!ModelCapabilitiesCache.hasTestedThinkingSupport(ANTHROPIC, settings.defaultModel)) { - val supportsThinking = probeThinkingSupport(settings, jdkHttpClientBuilder) + val supportsThinking = probeThinkingSupport(settings) ModelCapabilitiesCache.setThinkingSupport(ANTHROPIC, settings.defaultModel, supportsThinking) } @@ -132,13 +124,10 @@ class AnthropicModelFactory : ChatModelFactory { * * This is called only once per model — the result is cached in [ModelCapabilitiesCache]. */ - private fun probeThinkingSupport( - settings: AnthropicSettings, - jdkHttpClientBuilder: JdkHttpClientBuilder, - ): Boolean = try { + private fun probeThinkingSupport(settings: AnthropicSettings): Boolean = try { val testModel = AnthropicStreamingChatModel .builder() - .httpClientBuilder(jdkHttpClientBuilder) + .httpClientBuilder(createJdkHttpClientBuilder()) .apiKey(safeApiKey(settings.apiKey)) .modelName(settings.defaultModel) .baseUrl(settings.baseUrl) @@ -167,15 +156,13 @@ class AnthropicModelFactory : ChatModelFactory { } override fun createStreamingModel(settings: AnthropicSettings): StreamingChatModel { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) val telemetry = AppContext.getInstance().telemetry val supportsThinking = ModelCapabilitiesCache.supportsThinking(ANTHROPIC, settings.defaultModel) val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(ANTHROPIC, settings.defaultModel) return AnthropicStreamingChatModel.builder() - .httpClientBuilder(jdkHttpClientBuilder) + .httpClientBuilder(createJdkHttpClientBuilder()) .apiKey(safeApiKey(settings.apiKey)) .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) .modelName(settings.defaultModel) @@ -212,34 +199,25 @@ class AnthropicModelFactory : ChatModelFactory { .build() } - override fun createSecondaryModel(settings: AnthropicSettings): ChatModel { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) - return AnthropicChatModel.builder() - .httpClientBuilder(jdkHttpClientBuilder) - .apiKey(safeApiKey(settings.apiKey)) - .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) - .modelName( - settings.utilityModel - .ifBlank { settings.defaultModel }, - ) - .baseUrl(settings.baseUrl) - .timeout(Duration.ofSeconds(AppConfig.models.timeouts.utilityModelTimeoutSeconds)) - .build() - } - - override fun createModel(settings: AnthropicSettings): ChatModel { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) + override fun createSecondaryModel(settings: AnthropicSettings): ChatModel = AnthropicChatModel.builder() + .httpClientBuilder(createJdkHttpClientBuilder()) + .apiKey(safeApiKey(settings.apiKey)) + .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) + .modelName( + settings.utilityModel + .ifBlank { settings.defaultModel }, + ) + .baseUrl(settings.baseUrl) + .timeout(Duration.ofSeconds(AppConfig.models.timeouts.utilityModelTimeoutSeconds)) + .build() - return AnthropicChatModel.builder() - .httpClientBuilder(jdkHttpClientBuilder) - .apiKey(safeApiKey(settings.apiKey)) - .modelName(settings.defaultModel) - .baseUrl(settings.baseUrl) - .timeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) - .build() - } + override fun createModel(settings: AnthropicSettings): ChatModel = AnthropicChatModel.builder() + .httpClientBuilder(createJdkHttpClientBuilder()) + .apiKey(safeApiKey(settings.apiKey)) + .modelName(settings.defaultModel) + .baseUrl(settings.baseUrl) + .timeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) + .build() override fun createUtilityClient( settings: AnthropicSettings, diff --git a/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiModelFactory.kt index 0595df792..df1fa3c33 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiModelFactory.kt @@ -4,59 +4,30 @@ */ package io.askimo.core.providers.docker -import dev.langchain4j.model.chat.ChatModel -import dev.langchain4j.model.chat.StreamingChatModel -import dev.langchain4j.model.openai.OpenAiChatModel -import dev.langchain4j.model.openai.OpenAiStreamingChatModel import io.askimo.core.context.AppContext -import io.askimo.core.providers.ModelCapabilitiesCache import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ModelProvider.DOCKER -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory import io.askimo.core.providers.ensureLocalEmbeddingModelAvailable -import io.askimo.core.telemetry.TelemetryChatModelListener -import java.net.http.HttpClient +import io.askimo.core.providers.openaicompatible.CompletionsApiDelegate +import io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory +import io.askimo.core.providers.openaicompatible.ResponsesApiDelegate -class DockerAiModelFactory : OpenAiCompatibleChatModelFactory() { +/** + * Model factory for Docker AI. + * + * Uses [CompletionsApiDelegate] (`/v1/chat/completions`) since Docker AI does not support + * the OpenAI Responses API. HTTP/1.1 is enforced via [DockerAiSettings.httpVersion] — + * Docker AI's server stack does not support HTTP/2. + */ +class DockerAiModelFactory : + OpenAiCompatibleChatModelFactory( + apiDelegate = ResponsesApiDelegate(), + ) { override fun getProvider(): ModelProvider = DOCKER override fun defaultSettings(): DockerAiSettings = DockerAiSettings() - /** Docker AI does not support HTTP/2. */ - override fun httpVersion(): HttpClient.Version = HttpClient.Version.HTTP_1_1 - - override fun createStreamingModel(settings: DockerAiSettings): StreamingChatModel { - val telemetry = AppContext.getInstance().telemetry - val supportsThinking = ModelCapabilitiesCache.supportsThinking(getProvider(), settings.defaultModel) - return OpenAiStreamingChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl)) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(settings.defaultModel) - .apply { - val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(getProvider(), settings.defaultModel) - if (supportsThinking && reasoningLevel.isEnabled) { - reasoningEffort(reasoningLevel.value) - } - } - .logRequests(log.isDebugEnabled) - .logResponses(log.isDebugEnabled) - .listeners(listOf(TelemetryChatModelListener(telemetry, getProvider().providerKey()))) - .build() - } - - override fun createSecondaryModel(settings: DockerAiSettings): ChatModel = OpenAiChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl)) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName( - settings.utilityModel - .ifBlank { utilityModelFallback(settings) }, - ) - .logRequests(log.isDebugEnabled) - .build() - /** * When no explicit utility model is configured, fall back to whichever model is currently * active in the session rather than [DockerAiSettings.defaultModel]. diff --git a/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiSettings.kt b/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiSettings.kt index 01fdbddc0..8498e550d 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiSettings.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/docker/DockerAiSettings.kt @@ -5,6 +5,7 @@ package io.askimo.core.providers.docker import io.askimo.core.providers.HasBaseUrl +import io.askimo.core.providers.HttpVersion import io.askimo.core.providers.ProviderConfigField import io.askimo.core.providers.ProviderSettings import io.askimo.core.providers.SettingField @@ -20,6 +21,8 @@ data class DockerAiSettings( override val embeddingModel: String = "", ) : ProviderSettings, HasBaseUrl { + /** Docker AI does not support HTTP/2 — force HTTP/1.1 for all connections. */ + override val httpVersion: HttpVersion get() = HttpVersion.HTTP_1_1 override fun describe(): List = listOf( "baseUrl: $baseUrl", ) diff --git a/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt index 9919d314d..13d2e489a 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt @@ -5,8 +5,6 @@ package io.askimo.core.providers.gemini import dev.langchain4j.data.message.UserMessage -import dev.langchain4j.http.client.jdk.JdkHttpClient -import dev.langchain4j.http.client.jdk.JdkHttpClientBuilder import dev.langchain4j.memory.ChatMemory import dev.langchain4j.model.chat.Capability import dev.langchain4j.model.chat.ChatModel @@ -38,13 +36,11 @@ import io.askimo.core.providers.ReasoningEffort import io.askimo.core.providers.sendStreamingMessageWithCallback import io.askimo.core.telemetry.TelemetryChatModelListener import io.askimo.core.util.ApiKeyUtils.safeApiKey -import io.askimo.core.util.ProxyUtil -import io.askimo.core.util.withLoggingIfDebug +import io.askimo.core.util.createJdkHttpClientBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import java.net.http.HttpClient import java.time.Duration class GeminiModelFactory : ChatModelFactory { @@ -70,12 +66,9 @@ class GeminiModelFactory : ChatModelFactory { executionMode: ExecutionMode, chatMemory: ChatMemory?, ): ChatClient { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) - // Probe thinking support once — result is persisted in ModelCapabilitiesCache if (!ModelCapabilitiesCache.hasTestedThinkingSupport(GEMINI, settings.defaultModel)) { - val supportsThinking = probeThinkingSupport(settings, jdkHttpClientBuilder) + val supportsThinking = probeThinkingSupport(settings) ModelCapabilitiesCache.setThinkingSupport(GEMINI, settings.defaultModel, supportsThinking) } @@ -129,13 +122,10 @@ class GeminiModelFactory : ChatModelFactory { * * This is called only once per model — the result is cached in [ModelCapabilitiesCache]. */ - private fun probeThinkingSupport( - settings: GeminiSettings, - jdkHttpClientBuilder: JdkHttpClientBuilder, - ): Boolean = try { + private fun probeThinkingSupport(settings: GeminiSettings): Boolean = try { val testModel = GoogleAiGeminiStreamingChatModel .builder() - .httpClientBuilder(jdkHttpClientBuilder) + .httpClientBuilder(createJdkHttpClientBuilder()) .apiKey(safeApiKey(settings.apiKey)) .modelName(settings.defaultModel) .thinkingConfig( @@ -168,15 +158,13 @@ class GeminiModelFactory : ChatModelFactory { .build() override fun createStreamingModel(settings: GeminiSettings): StreamingChatModel { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) val telemetry = AppContext.getInstance().telemetry val supportsThinking = ModelCapabilitiesCache.supportsThinking(GEMINI, settings.defaultModel) val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(GEMINI, settings.defaultModel) return GoogleAiGeminiStreamingChatModel.builder() - .httpClientBuilder(jdkHttpClientBuilder) + .httpClientBuilder(createJdkHttpClientBuilder()) .apiKey(safeApiKey(settings.apiKey)) .modelName(settings.defaultModel) .timeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) @@ -201,32 +189,23 @@ class GeminiModelFactory : ChatModelFactory { .build() } - override fun createSecondaryModel(settings: GeminiSettings): ChatModel { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) - return GoogleAiGeminiChatModel.builder() - .httpClientBuilder(jdkHttpClientBuilder) - .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) - .apiKey(safeApiKey(settings.apiKey)) - .modelName( - settings.utilityModel - .ifBlank { settings.defaultModel }, - ) - .timeout(Duration.ofSeconds(AppConfig.models.timeouts.utilityModelTimeoutSeconds)) - .build() - } - - override fun createModel(settings: GeminiSettings): ChatModel { - val httpClientBuilder = ProxyUtil.configureProxy(HttpClient.newBuilder()).withLoggingIfDebug() - val jdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder(httpClientBuilder) + override fun createSecondaryModel(settings: GeminiSettings): ChatModel = GoogleAiGeminiChatModel.builder() + .httpClientBuilder(createJdkHttpClientBuilder()) + .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) + .apiKey(safeApiKey(settings.apiKey)) + .modelName( + settings.utilityModel + .ifBlank { settings.defaultModel }, + ) + .timeout(Duration.ofSeconds(AppConfig.models.timeouts.utilityModelTimeoutSeconds)) + .build() - return GoogleAiGeminiChatModel.builder() - .httpClientBuilder(jdkHttpClientBuilder) - .apiKey(safeApiKey(settings.apiKey)) - .modelName(settings.defaultModel) - .timeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) - .build() - } + override fun createModel(settings: GeminiSettings): ChatModel = GoogleAiGeminiChatModel.builder() + .httpClientBuilder(createJdkHttpClientBuilder()) + .apiKey(safeApiKey(settings.apiKey)) + .modelName(settings.defaultModel) + .timeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) + .build() override fun createUtilityClient( settings: GeminiSettings, diff --git a/shared/src/main/kotlin/io/askimo/core/providers/lmstudio/LmStudioModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/lmstudio/LmStudioModelFactory.kt index aee94333d..683f66bcd 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/lmstudio/LmStudioModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/lmstudio/LmStudioModelFactory.kt @@ -8,10 +8,23 @@ import dev.langchain4j.model.openai.OpenAiEmbeddingModel.OpenAiEmbeddingModelBui import io.askimo.core.context.AppContext import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ModelProvider.LMSTUDIO -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory import io.askimo.core.providers.ensureLocalEmbeddingModelAvailable +import io.askimo.core.providers.openaicompatible.CompletionsApiDelegate +import io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory +import io.askimo.core.providers.openaicompatible.ResponsesApiDelegate +import io.askimo.core.util.toJdkVersion -class LmStudioModelFactory : OpenAiCompatibleChatModelFactory() { +/** + * Model factory for LM Studio. + * + * Uses [CompletionsApiDelegate] (`/v1/chat/completions`) since LM Studio does not support + * the OpenAI Responses API. HTTP/1.1 is enforced via [LmStudioSettings.httpVersion] — + * LM Studio's server does not support HTTP/2. + */ +class LmStudioModelFactory : + OpenAiCompatibleChatModelFactory( + apiDelegate = ResponsesApiDelegate(), + ) { override fun getProvider(): ModelProvider = LMSTUDIO @@ -25,9 +38,11 @@ class LmStudioModelFactory : OpenAiCompatibleChatModelFactory( override fun checkEmbeddingAvailability(baseUrl: String, modelName: String) = ensureLocalEmbeddingModelAvailable(getProvider(), baseUrl, modelName) - /** LmStudio requires an HTTP/1.1 client on the embedding builder as well. */ + /** LM Studio requires an HTTP/1.1 client on the embedding builder as well. */ override fun customizeEmbeddingBuilder( settings: LmStudioSettings, builder: OpenAiEmbeddingModelBuilder, - ): OpenAiEmbeddingModelBuilder = builder.httpClientBuilder(createHttpClientBuilder(settings.baseUrl)) + ): OpenAiEmbeddingModelBuilder = builder.httpClientBuilder( + createHttpClientBuilder(settings.baseUrl, httpVersion = settings.httpVersion.toJdkVersion()), + ) } diff --git a/shared/src/main/kotlin/io/askimo/core/providers/localai/LocalAiModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/localai/LocalAiModelFactory.kt index 5ea1b8b74..791cdc400 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/localai/LocalAiModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/localai/LocalAiModelFactory.kt @@ -6,10 +6,14 @@ package io.askimo.core.providers.localai import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ModelProvider.LOCALAI -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory import io.askimo.core.providers.ensureLocalEmbeddingModelAvailable +import io.askimo.core.providers.openaicompatible.CompletionsApiDelegate +import io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory -class LocalAiModelFactory : OpenAiCompatibleChatModelFactory() { +class LocalAiModelFactory : + OpenAiCompatibleChatModelFactory( + apiDelegate = CompletionsApiDelegate(), + ) { override fun getProvider(): ModelProvider = LOCALAI diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ollama/OllamaModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/ollama/OllamaModelFactory.kt index bb03f8ff6..6faf765f1 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ollama/OllamaModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ollama/OllamaModelFactory.kt @@ -5,10 +5,14 @@ package io.askimo.core.providers.ollama import io.askimo.core.providers.ModelProvider -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory import io.askimo.core.providers.ensureLocalEmbeddingModelAvailable +import io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory +import io.askimo.core.providers.openaicompatible.ResponsesApiDelegate -class OllamaModelFactory : OpenAiCompatibleChatModelFactory() { +class OllamaModelFactory : + OpenAiCompatibleChatModelFactory( + apiDelegate = ResponsesApiDelegate(), + ) { override fun getProvider(): ModelProvider = ModelProvider.OLLAMA diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openai/OpenAiModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/openai/OpenAiModelFactory.kt index 87d35912e..3bc5d80b6 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openai/OpenAiModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openai/OpenAiModelFactory.kt @@ -7,7 +7,7 @@ package io.askimo.core.providers.openai import dev.langchain4j.model.openai.OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ModelProvider.OPENAI -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory +import io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory import io.askimo.core.util.ApiKeyUtils.safeApiKey class OpenAiModelFactory : OpenAiCompatibleChatModelFactory() { diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiDelegate.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiDelegate.kt new file mode 100644 index 000000000..affdbff46 --- /dev/null +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiDelegate.kt @@ -0,0 +1,264 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.providers.openaicompatible + +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.http.client.jdk.JdkHttpClientBuilder +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.StreamingChatModel +import dev.langchain4j.model.openai.OpenAiChatModel +import dev.langchain4j.model.openai.OpenAiResponsesChatModel +import dev.langchain4j.model.openai.OpenAiResponsesStreamingChatModel +import dev.langchain4j.model.openai.OpenAiStreamingChatModel +import dev.langchain4j.service.AiServices +import io.askimo.core.providers.ChatClient +import io.askimo.core.providers.ModelCapabilitiesCache +import io.askimo.core.providers.ModelProvider +import io.askimo.core.providers.ReasoningEffort +import io.askimo.core.providers.sendStreamingMessageWithCallback +import io.askimo.core.telemetry.TelemetryChatModelListener +import org.slf4j.Logger + +/** + * Strategy interface that encapsulates which OpenAI API surface a provider targets + * and whether thinking/reasoning probing is supported. + * + * Injected into [OpenAiCompatibleChatModelFactory] at + * construction time so that HTTP-version handling (orthogonal, lives in settings) + * and API-surface selection are both cleanly separated from the factory lifecycle. + * + * Two concrete implementations: + * - [CompletionsApiDelegate] — `/v1/chat/completions`; thinking always `false` + * - [ResponsesApiDelegate] — `/v1/responses`; thinking probed via HTTP on first use + */ +sealed interface OpenAiApiDelegate { + + /** + * Creates the primary streaming model for the conversation loop. + * The [httpClientBuilder] is already configured with the correct HTTP version, + * proxy, and timeouts by the calling factory. + */ + fun createStreamingModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + provider: ModelProvider, + ): StreamingChatModel + + /** + * Creates the non-streaming secondary (utility) model for cheap background tasks + * (RAG compression, title generation, intent classification, etc.). + */ + fun createSecondaryModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + ): ChatModel + + /** + * Creates a non-streaming model matching [createStreamingModel]'s configuration + * (same model name, same reasoning settings where applicable). + */ + fun createModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + provider: ModelProvider, + ): ChatModel + + /** + * Probes whether the model/endpoint supports thinking/reasoning. + * + * [CompletionsApiDelegate] returns `false` immediately — the `/v1/chat/completions` + * surface has no reasoning support. [ResponsesApiDelegate] fires a minimal HTTP probe + * against the `/v1/responses` endpoint, caching the result via [ModelCapabilitiesCache]. + */ + fun probeThinkingSupport( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + log: Logger, + ): Boolean +} + +// ── Delegate: Chat Completions API (/v1/chat/completions) ──────────────────────────────── + +/** + * Builds models using the standard `/v1/chat/completions` endpoint. + * + * Use for virtually every third-party OpenAI-compatible provider (NVIDIA NIM, OpenRouter, + * Groq, Together AI, Cloudflare AI, Ollama, LM Studio, Docker AI, LocalAI, etc.). + * Thinking is hard-wired to `false` — the Completions surface has no reasoning support. + */ +class CompletionsApiDelegate : OpenAiApiDelegate { + + override fun createStreamingModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + provider: ModelProvider, + ): StreamingChatModel = OpenAiStreamingChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .listeners(listOf(listener)) + .build() + + override fun createSecondaryModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + ): ChatModel = OpenAiChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .listeners(listOf(listener)) + .build() + + override fun createModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + provider: ModelProvider, + ): ChatModel = OpenAiChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .listeners(listOf(listener)) + .build() + + /** Chat Completions surface has no reasoning support — always returns `false`. */ + override fun probeThinkingSupport( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + log: Logger, + ): Boolean = false +} + +// ── Delegate: Responses API (/v1/responses) ───────────────────────────────────────────── + +/** + * Builds models using the OpenAI Responses API (`/v1/responses`) with typed `input[]` + * content parts and optional reasoning/thinking support. + * + * Use when the endpoint explicitly supports `/v1/responses` (native OpenAI, xAI/Grok, + * or a compatible gateway). Most third-party providers do **not** support this endpoint. + * This is the default for [OpenAiCompatibleChatModelFactory]. + */ +class ResponsesApiDelegate : OpenAiApiDelegate { + + override fun createStreamingModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + provider: ModelProvider, + ): StreamingChatModel { + val supportsThinking = ModelCapabilitiesCache.supportsThinking(provider, modelName) + return OpenAiResponsesStreamingChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .apply { + val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(provider, modelName) + if (supportsThinking && reasoningLevel.isEnabled) { + reasoningEffort(reasoningLevel.value) + reasoningSummary("detailed") + } + } + .strictTools(true) + .listeners(listOf(listener)) + .build() + } + + override fun createSecondaryModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + ): ChatModel = OpenAiResponsesChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .listeners(listOf(listener)) + .build() + + override fun createModel( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + listener: TelemetryChatModelListener, + provider: ModelProvider, + ): ChatModel { + val supportsThinking = ModelCapabilitiesCache.supportsThinking(provider, modelName) + return OpenAiResponsesChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .apply { + val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(provider, modelName) + if (supportsThinking && reasoningLevel.isEnabled) { + reasoningEffort(reasoningLevel.value) + } + } + .listeners(listOf(listener)) + .build() + } + + /** + * Fires a minimal HTTP probe against the `/v1/responses` endpoint with + * `reasoning_effort: low` to test if the model accepts reasoning parameters. + */ + override fun probeThinkingSupport( + baseUrl: String, + apiKey: String, + modelName: String, + httpClientBuilder: JdkHttpClientBuilder, + log: Logger, + ): Boolean = try { + val testModel = OpenAiResponsesStreamingChatModel.builder() + .httpClientBuilder(httpClientBuilder) + .baseUrl(baseUrl) + .apiKey(apiKey) + .modelName(modelName) + .reasoningEffort(ReasoningEffort.LOW.value) + .build() + + val testClient = AiServices.builder(ChatClient::class.java) + .streamingChatModel(testModel) + .build() + + testClient.sendStreamingMessageWithCallback(null, UserMessage("Capability probe — reply with 'ok'.")) + log.info("Model '$modelName' supports thinking — thinking enabled") + true + } catch (e: Exception) { + log.info("Model '$modelName' does not support thinking: ${e.message} — thinking disabled", e) + false + } +} diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiMode.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiMode.kt index c2c6e46c5..6fb9eb571 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiMode.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiApiMode.kt @@ -21,18 +21,3 @@ enum class OpenAiApiMode { CHAT_COMPLETIONS, RESPONSES, } - -/** - * HTTP protocol version used for connections to an OpenAI-compatible endpoint. - * - * - [HTTP_1_1] — use for self-hosted servers running on uvicorn, Gunicorn, FastAPI, or vLLM. - * These stacks commonly have incomplete HTTP/2 support that causes the request body to - * arrive as null. **This is the safe default for the OpenAI-compatible provider.** - * - * - [HTTP_2] — use for production cloud endpoints (OpenAI, Groq, OpenRouter, etc.) that - * fully support HTTP/2 multiplexing. - */ -enum class OpenAiHttpVersion { - HTTP_1_1, - HTTP_2, -} diff --git a/shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleChatModelFactory.kt similarity index 69% rename from shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt rename to shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleChatModelFactory.kt index 60df0f83e..fd195d10a 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleChatModelFactory.kt @@ -2,9 +2,8 @@ * * Copyright (c) 2026 Askimo */ -package io.askimo.core.providers +package io.askimo.core.providers.openaicompatible -import dev.langchain4j.data.message.UserMessage import dev.langchain4j.http.client.jdk.JdkHttpClient import dev.langchain4j.memory.ChatMemory import dev.langchain4j.model.chat.ChatModel @@ -13,16 +12,24 @@ import dev.langchain4j.model.embedding.EmbeddingModel import dev.langchain4j.model.image.ImageModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder import dev.langchain4j.model.openai.OpenAiImageModel -import dev.langchain4j.model.openai.OpenAiResponsesChatModel -import dev.langchain4j.model.openai.OpenAiResponsesStreamingChatModel import dev.langchain4j.rag.content.retriever.ContentRetriever import dev.langchain4j.service.AiServices import dev.langchain4j.service.tool.ToolProvider import io.askimo.core.config.AppConfig import io.askimo.core.context.AppContext import io.askimo.core.context.ExecutionMode +import io.askimo.core.providers.AiServiceBuilder +import io.askimo.core.providers.ChatClient +import io.askimo.core.providers.ChatModelFactory +import io.askimo.core.providers.HasBaseUrl +import io.askimo.core.providers.LocalEmbeddingTokenLimits +import io.askimo.core.providers.ModelCapabilitiesCache +import io.askimo.core.providers.ModelDTO +import io.askimo.core.providers.ProviderModelUtils +import io.askimo.core.providers.ProviderSettings import io.askimo.core.telemetry.TelemetryChatModelListener import io.askimo.core.util.ProxyUtil +import io.askimo.core.util.toJdkVersion import io.askimo.core.util.withLoggingIfDebug import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,17 +39,24 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.net.http.HttpClient import java.time.Duration -import kotlin.Boolean /** * Abstract base factory for all OpenAI-compatible API providers. * - * Subclasses must implement [getProvider] and [defaultSettings]. - * Everything else has a sensible default that can be selectively overridden. + * Subclasses must implement [getProvider] and [io.askimo.core.providers.ChatModelFactory.defaultSettings]. + * The [apiDelegate] selects which OpenAI API surface is used ([ResponsesApiDelegate] for + * `/v1/responses`, [CompletionsApiDelegate] for + * `/v1/chat/completions`) and owns `probeThinkingSupport`. + * HTTP version is declared per-provider via [io.askimo.core.providers.ProviderSettings.httpVersion]. * - * @param T Provider-specific settings. Must implement both [ProviderSettings] and [HasBaseUrl]. + * @param T Provider-specific settings. Must implement both [io.askimo.core.providers.ProviderSettings] and [io.askimo.core.providers.HasBaseUrl]. + * @param apiDelegate Strategy that builds models and probes thinking support. Defaults to + * [ResponsesApiDelegate]; pass [CompletionsApiDelegate] + * for providers that target `/v1/chat/completions`. */ -abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory +abstract class OpenAiCompatibleChatModelFactory( + protected val apiDelegate: OpenAiApiDelegate = ResponsesApiDelegate(), +) : ChatModelFactory where T : ProviderSettings, T : HasBaseUrl { /** @@ -65,24 +79,6 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory */ protected open fun resolveApiKey(settings: T): String = "not-needed" - /** - * HTTP protocol version used for all connections to this provider. - * - * Defaults to [HttpClient.Version.HTTP_2]. Override to [HttpClient.Version.HTTP_1_1] - * for servers that do not support HTTP/2 (e.g., LmStudio, Docker AI). - * - * Prefer overriding [httpVersion] that takes [settings] when the version should be - * read from per-instance settings rather than being a class-level constant. - */ - protected open fun httpVersion(): HttpClient.Version = HttpClient.Version.HTTP_2 - - /** - * Settings-aware variant of [httpVersion]. Called by the base class at every - * model-build and model-list site. The default delegates to [httpVersion] so all - * existing subclasses that override the no-arg form continue to work unchanged. - */ - protected open fun httpVersion(settings: T): HttpClient.Version = httpVersion() - /** * Fallback model name used for the secondary/utility model when no explicit utility model * is configured in [AppConfig]. @@ -104,7 +100,7 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory * Template method for embedding model availability verification. * * **Local providers** (Ollama, LocalAI, LmStudio, Docker AI) override this to call - * [ensureLocalEmbeddingModelAvailable], which verifies the server is reachable and the + * [io.askimo.core.providers.ensureLocalEmbeddingModelAvailable], which verifies the server is reachable and the * requested model is pulled/available. * * **Remote / cloud providers** leave this as a no-op — the embedding endpoint is assumed @@ -125,29 +121,18 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory ): OpenAiEmbeddingModelBuilder = builder /** - * Probe whether the current model supports thinking/reasoning capabilities. + * Delegates thinking-support probing to [apiDelegate]. + * [CompletionsApiDelegate] returns `false` + * immediately; [ResponsesApiDelegate] fires a live HTTP probe. + * Result is cached by the calling [create] method. */ - protected open fun probeThinkingSupport(settings: T): Boolean = try { - val testModel = OpenAiResponsesStreamingChatModel - .builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, httpVersion = httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(settings.defaultModel) - .reasoningEffort(ReasoningEffort.LOW.value) - .build() - - val testClient = AiServices.builder(ChatClient::class.java) - .streamingChatModel(testModel) - .build() - - testClient.sendStreamingMessageWithCallback(null, UserMessage("Capability probe — reply with 'ok'.")) - log.info("Model '${settings.defaultModel}' supports thinking — thinking enabled") - true - } catch (e: Exception) { - log.info("Model '${settings.defaultModel}' does not support thinking: ${e.message} — thinking disabled", e) - false - } + protected open fun probeThinkingSupport(settings: T): Boolean = apiDelegate.probeThinkingSupport( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = settings.defaultModel, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, httpVersion = settings.httpVersion.toJdkVersion()), + log = log, + ) // ── Shared helpers ───────────────────────────────────────────────────────── @@ -157,12 +142,15 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory * * [listener] is provided when the HTTP client should be wired to a specific * [TelemetryChatModelListener] instance — e.g. to inject per-request headers that are - * derived from that listener. The default implementation ignores it; + * derived from that listener. The default implementation ignores it. + * + * The HTTP version defaults to [HttpClient.Version.HTTP_2]; callers pass + * `settings.httpVersion.toJdkVersion()` to respect the per-provider/per-instance setting. */ protected open fun createHttpClientBuilder( baseUrl: String, listener: TelemetryChatModelListener? = null, - httpVersion: HttpClient.Version = httpVersion(), + httpVersion: HttpClient.Version = HttpClient.Version.HTTP_2, ) = JdkHttpClient.builder().httpClientBuilder( ProxyUtil.configureProxy( HttpClient.newBuilder().version(httpVersion), @@ -184,7 +172,7 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory apiKey = resolveApiKey(settings), url = "${settings.baseUrl.trimEnd('/')}/models", providerName = getProvider(), - httpVersion = httpVersion(settings), + httpVersion = settings.httpVersion.toJdkVersion(), ).map { ModelDTO.of(getProvider(), it) } } @@ -249,53 +237,38 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory override fun createStreamingModel(settings: T): StreamingChatModel { val listener = createTelemetryListener() - val supportsThinking = ModelCapabilitiesCache.supportsThinking(getProvider(), settings.defaultModel) - return OpenAiResponsesStreamingChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, listener, httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(settings.defaultModel) - .apply { - val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(getProvider(), settings.defaultModel) - if (supportsThinking && reasoningLevel.isEnabled) { - reasoningEffort(reasoningLevel.value) - reasoningSummary("detailed") - } - } - .strictTools(true) - .listeners(listOf(listener)) - .build() + return apiDelegate.createStreamingModel( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = settings.defaultModel, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, listener, settings.httpVersion.toJdkVersion()), + listener = listener, + provider = getProvider(), + ) } override fun createSecondaryModel(settings: T): ChatModel { val listener = createTelemetryListener() - val modelName = settings.utilityModel - .ifBlank { utilityModelFallback(settings) } - return OpenAiResponsesChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, listener, httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(modelName) - .listeners(listOf(listener)) - .build() + val modelName = settings.utilityModel.ifBlank { utilityModelFallback(settings) } + return apiDelegate.createSecondaryModel( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = modelName, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, listener, settings.httpVersion.toJdkVersion()), + listener = listener, + ) } override fun createModel(settings: T): ChatModel { val listener = createTelemetryListener() - val supportsThinking = ModelCapabilitiesCache.supportsThinking(getProvider(), settings.defaultModel) - return OpenAiResponsesChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, listener, httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(settings.defaultModel) - .apply { - val reasoningLevel = ModelCapabilitiesCache.getReasoningLevel(getProvider(), settings.defaultModel) - if (supportsThinking && reasoningLevel.isEnabled) { - reasoningEffort(reasoningLevel.value) - } - } - .listeners(listOf(listener)) - .build() + return apiDelegate.createModel( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = settings.defaultModel, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, listener, settings.httpVersion.toJdkVersion()), + listener = listener, + provider = getProvider(), + ) } override fun createImageModel(settings: T): ImageModel = OpenAiImageModel.builder() diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleModelFactory.kt index d9a10228e..0e8e839d1 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleModelFactory.kt @@ -7,125 +7,78 @@ package io.askimo.core.providers.openaicompatible import dev.langchain4j.model.chat.ChatModel import dev.langchain4j.model.chat.StreamingChatModel import dev.langchain4j.model.embedding.EmbeddingModel -import dev.langchain4j.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder -import dev.langchain4j.model.openai.OpenAiStreamingChatModel import io.askimo.core.providers.ModelProvider -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory import io.askimo.core.util.ApiKeyUtils.safeApiKey -import java.net.http.HttpClient +import io.askimo.core.util.toJdkVersion /** - * Intermediate base that adds [shouldProbeThinking] as a stable, non-protected surface so - * [OpenAiCompatibleModelFactory] can call it on whichever delegate [delegate] returns — - * without a `when` expression and without touching the visibility of the base-class method. + * The registered factory for [ModelProvider.OPENAI_COMPATIBLE]. + * + * Routes model creation to [CompletionsApiDelegate] (`/v1/chat/completions`) or + * [ResponsesApiDelegate] (`/v1/responses`) based on [OpenAiCompatibleSettings.apiMode], + * which is user-configurable per instance. + * + * HTTP version and API mode are orthogonal: the delegate controls the endpoint surface; + * [OpenAiCompatibleSettings.httpVersion] controls the transport version. */ -internal abstract class OpenAiCompatibleDelegateFactory : OpenAiCompatibleChatModelFactory() { +class OpenAiCompatibleModelFactory : OpenAiCompatibleChatModelFactory() { - /** Expose the protected [probeThinkingSupport] to the router as an internal method. */ - internal fun shouldProbeThinking(settings: OpenAiCompatibleSettings): Boolean = probeThinkingSupport(settings) + private val completionsApiDelegate = CompletionsApiDelegate() + private val responsesApiDelegate = ResponsesApiDelegate() - /** - * Read the HTTP version from per-instance [settings] rather than using a class-level constant. - * Maps [OpenAiHttpVersion] to the JDK [HttpClient.Version] enum. - */ - override fun httpVersion(settings: OpenAiCompatibleSettings): HttpClient.Version = when (settings.httpVersion) { - OpenAiHttpVersion.HTTP_1_1 -> HttpClient.Version.HTTP_1_1 - OpenAiHttpVersion.HTTP_2 -> HttpClient.Version.HTTP_2 + private fun delegate(settings: OpenAiCompatibleSettings): OpenAiApiDelegate = when (settings.apiMode) { + OpenAiApiMode.CHAT_COMPLETIONS -> completionsApiDelegate + OpenAiApiMode.RESPONSES -> responsesApiDelegate } -} - -// ── Delegate: Chat Completions API (/v1/chat/completions) ───────────────────────────────── - -/** - * Handles the standard Chat Completions API path. - * Used for virtually every third-party OpenAI-compatible provider - * (NVIDIA NIM, OpenRouter, Groq, Together AI, Cloudflare AI, etc.). - */ -internal class OpenAiCompatibleCompletionsModelFactory : OpenAiCompatibleDelegateFactory() { override fun getProvider(): ModelProvider = ModelProvider.OPENAI_COMPATIBLE override fun defaultSettings(): OpenAiCompatibleSettings = OpenAiCompatibleSettings() override fun resolveApiKey(settings: OpenAiCompatibleSettings): String = safeApiKey(settings.apiKey.ifBlank { "not-needed" }) - /** Chat Completions providers don't support /v1/responses — thinking is never available. */ - override fun probeThinkingSupport(settings: OpenAiCompatibleSettings): Boolean = false + override fun probeThinkingSupport(settings: OpenAiCompatibleSettings): Boolean = delegate(settings).probeThinkingSupport( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = settings.defaultModel, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, httpVersion = settings.httpVersion.toJdkVersion()), + log = log, + ) override fun createStreamingModel(settings: OpenAiCompatibleSettings): StreamingChatModel { val listener = createTelemetryListener() - return OpenAiStreamingChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, listener, httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(settings.defaultModel) - .listeners(listOf(listener)) - .build() + return delegate(settings).createStreamingModel( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = settings.defaultModel, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, listener, settings.httpVersion.toJdkVersion()), + listener = listener, + provider = getProvider(), + ) } override fun createSecondaryModel(settings: OpenAiCompatibleSettings): ChatModel { val listener = createTelemetryListener() - val modelName = settings.utilityModel - .ifBlank { utilityModelFallback(settings) } - return OpenAiChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, listener, httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(modelName) - .listeners(listOf(listener)) - .build() + val modelName = settings.utilityModel.ifBlank { utilityModelFallback(settings) } + return delegate(settings).createSecondaryModel( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = modelName, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, listener, settings.httpVersion.toJdkVersion()), + listener = listener, + ) } override fun createModel(settings: OpenAiCompatibleSettings): ChatModel { val listener = createTelemetryListener() - return OpenAiChatModel.builder() - .httpClientBuilder(createHttpClientBuilder(settings.baseUrl, listener, httpVersion(settings))) - .baseUrl(settings.baseUrl) - .apiKey(resolveApiKey(settings)) - .modelName(settings.defaultModel) - .listeners(listOf(listener)) - .build() + return delegate(settings).createModel( + baseUrl = settings.baseUrl, + apiKey = resolveApiKey(settings), + modelName = settings.defaultModel, + httpClientBuilder = createHttpClientBuilder(settings.baseUrl, listener, settings.httpVersion.toJdkVersion()), + listener = listener, + provider = getProvider(), + ) } -} - -// ── Delegate: Responses API (/v1/responses) ─────────────────────────────────────────────── - -/** - * Handles the OpenAI Responses API path with typed content parts and thinking support. - * Use when pointing at an endpoint that explicitly supports `/v1/responses` - * (native OpenAI, or a compatible gateway). Inherits all Responses API model creation - * from [OpenAiCompatibleChatModelFactory] — no overrides needed. - */ -internal class OpenAiCompatibleResponsesModelFactory : OpenAiCompatibleDelegateFactory() { - - override fun getProvider(): ModelProvider = ModelProvider.OPENAI_COMPATIBLE - override fun defaultSettings(): OpenAiCompatibleSettings = OpenAiCompatibleSettings() - override fun resolveApiKey(settings: OpenAiCompatibleSettings): String = safeApiKey(settings.apiKey.ifBlank { "not-needed" }) -} - -/** - * The registered factory for [ModelProvider.OPENAI_COMPATIBLE]. - */ -class OpenAiCompatibleModelFactory : OpenAiCompatibleChatModelFactory() { - - private val completionsDelegate = OpenAiCompatibleCompletionsModelFactory() - private val responsesDelegate = OpenAiCompatibleResponsesModelFactory() - - private fun delegate(settings: OpenAiCompatibleSettings): OpenAiCompatibleDelegateFactory = when (settings.apiMode) { - OpenAiApiMode.CHAT_COMPLETIONS -> completionsDelegate - OpenAiApiMode.RESPONSES -> responsesDelegate - } - - override fun getProvider(): ModelProvider = ModelProvider.OPENAI_COMPATIBLE - override fun defaultSettings(): OpenAiCompatibleSettings = OpenAiCompatibleSettings() - override fun resolveApiKey(settings: OpenAiCompatibleSettings): String = safeApiKey(settings.apiKey.ifBlank { "not-needed" }) - - override fun createStreamingModel(settings: OpenAiCompatibleSettings): StreamingChatModel = delegate(settings).createStreamingModel(settings) - - override fun createSecondaryModel(settings: OpenAiCompatibleSettings): ChatModel = delegate(settings).createSecondaryModel(settings) - - override fun createModel(settings: OpenAiCompatibleSettings): ChatModel = delegate(settings).createModel(settings) - - override fun probeThinkingSupport(settings: OpenAiCompatibleSettings): Boolean = delegate(settings).shouldProbeThinking(settings) override fun getNoModelsHelpText(): String = """ One possible reason is that your server URL or API key is not configured. diff --git a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt index d2ecb7cbf..e9141eeeb 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/openaicompatible/OpenAiCompatibleSettings.kt @@ -6,6 +6,7 @@ package io.askimo.core.providers.openaicompatible import io.askimo.core.providers.HasApiKey import io.askimo.core.providers.HasBaseUrl +import io.askimo.core.providers.HttpVersion import io.askimo.core.providers.ProviderConfigField import io.askimo.core.providers.ProviderSettings import io.askimo.core.providers.SelectOption @@ -30,23 +31,25 @@ data class OpenAiCompatibleSettings( val apiMode: OpenAiApiMode = OpenAiApiMode.CHAT_COMPLETIONS, /** * HTTP protocol version for connections to this endpoint. - * Defaults to [OpenAiHttpVersion.HTTP_1_1] — the safe choice for self-hosted servers - * (uvicorn, vLLM, FastAPI). Switch to [OpenAiHttpVersion.HTTP_2] for cloud endpoints. + * Defaults to [HttpVersion.HTTP_1_1] — the safe choice for self-hosted servers + * (uvicorn, vLLM, FastAPI). Switch to [HttpVersion.HTTP_2] for cloud endpoints. * Existing serialised configs without this field deserialise to the default safely. */ - val httpVersion: OpenAiHttpVersion = OpenAiHttpVersion.HTTP_1_1, + val httpVersionConfig: HttpVersion = HttpVersion.HTTP_1_1, ) : ProviderSettings, HasApiKey, HasBaseUrl { + override val httpVersion: HttpVersion get() = httpVersionConfig + override fun describe(): List = listOf( "baseUrl: $baseUrl", "apiKey: ${maskApiKey()}", "apiMode: $apiMode", - "httpVersion: $httpVersion", + "httpVersion: $httpVersionConfig", ) - override fun toString(): String = "OpenAiCompatibleSettings(baseUrl=$baseUrl, apiKey=${maskApiKey()}, apiMode=$apiMode, httpVersion=$httpVersion)" + override fun toString(): String = "OpenAiCompatibleSettings(baseUrl=$baseUrl, apiKey=${maskApiKey()}, apiMode=$apiMode, httpVersion=$httpVersionConfig)" override fun getFields() = listOf( SettingField.TextField( @@ -84,7 +87,7 @@ data class OpenAiCompatibleSettings( ) SettingField.HTTP_VERSION -> copy( - httpVersion = runCatching { OpenAiHttpVersion.valueOf(value) }.getOrDefault(httpVersion), + httpVersionConfig = runCatching { HttpVersion.valueOf(value) }.getOrDefault(httpVersionConfig), ) else -> this @@ -112,6 +115,7 @@ data class OpenAiCompatibleSettings( ProviderConfigField.ApiKeyField( description = apiKeyDescription, value = apiKey, + required = false, hasExistingValue = hasStoredKey, ), ProviderConfigField.SelectField( @@ -136,15 +140,15 @@ data class OpenAiCompatibleSettings( name = SettingField.HTTP_VERSION, label = messageResolver("provider.openai_compatible.httpversion.label"), description = messageResolver("provider.openai_compatible.httpversion.description"), - value = httpVersion.name, + value = httpVersionConfig.name, options = listOf( SelectOption( - value = OpenAiHttpVersion.HTTP_1_1.name, + value = HttpVersion.HTTP_1_1.name, label = "HTTP/1.1", description = messageResolver("provider.openai_compatible.httpversion.http1_1"), ), SelectOption( - value = OpenAiHttpVersion.HTTP_2.name, + value = HttpVersion.HTTP_2.name, label = "HTTP/2", description = messageResolver("provider.openai_compatible.httpversion.http2"), ), @@ -160,9 +164,9 @@ data class OpenAiCompatibleSettings( ?.let { runCatching { OpenAiApiMode.valueOf(it) }.getOrNull() } ?: apiMode val newHttpVersion = fields[SettingField.HTTP_VERSION] - ?.let { runCatching { OpenAiHttpVersion.valueOf(it) }.getOrNull() } - ?: httpVersion - return copy(baseUrl = newBaseUrl, apiKey = newApiKey, apiMode = newApiMode, httpVersion = newHttpVersion) + ?.let { runCatching { HttpVersion.valueOf(it) }.getOrNull() } + ?: httpVersionConfig + return copy(baseUrl = newBaseUrl, apiKey = newApiKey, apiMode = newApiMode, httpVersionConfig = newHttpVersion) } override fun deepCopy(): ProviderSettings = copy() diff --git a/shared/src/main/kotlin/io/askimo/core/providers/xai/XAiModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/xai/XAiModelFactory.kt index 22da521ac..8b4ec278b 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/xai/XAiModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/xai/XAiModelFactory.kt @@ -8,8 +8,8 @@ import dev.langchain4j.model.embedding.EmbeddingModel import io.askimo.core.providers.ModelDTO import io.askimo.core.providers.ModelProvider import io.askimo.core.providers.ModelProvider.XAI -import io.askimo.core.providers.OpenAiCompatibleChatModelFactory import io.askimo.core.providers.ProviderModelUtils.fetchModels +import io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory import io.askimo.core.util.ApiKeyUtils.safeApiKey class XAiModelFactory : OpenAiCompatibleChatModelFactory() { diff --git a/shared/src/main/kotlin/io/askimo/core/telemetry/TelemetryCollector.kt b/shared/src/main/kotlin/io/askimo/core/telemetry/TelemetryCollector.kt index bbfc0adad..0ae857a10 100644 --- a/shared/src/main/kotlin/io/askimo/core/telemetry/TelemetryCollector.kt +++ b/shared/src/main/kotlin/io/askimo/core/telemetry/TelemetryCollector.kt @@ -233,10 +233,10 @@ data class TelemetryMetrics( val ragAvgChunksRetrieved: Double, // LLM Calls — key is "$instanceId:$model" or "$provider:$model" (legacy) - val llmCallsByInstance: Map, - val llmTokensByInstance: Map, - val llmAvgDurationMsByInstance: Map, - val llmErrorsByInstance: Map, + val llmCallsByInstance: Map = emptyMap(), + val llmTokensByInstance: Map = emptyMap(), + val llmAvgDurationMsByInstance: Map = emptyMap(), + val llmErrorsByInstance: Map = emptyMap(), ) { val totalTokensUsed: Long get() = llmTokensByInstance.values.sum() diff --git a/shared/src/main/kotlin/io/askimo/core/util/HttpClientUtils.kt b/shared/src/main/kotlin/io/askimo/core/util/HttpClientUtils.kt new file mode 100644 index 000000000..42d7fb9f7 --- /dev/null +++ b/shared/src/main/kotlin/io/askimo/core/util/HttpClientUtils.kt @@ -0,0 +1,43 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.util + +import dev.langchain4j.http.client.jdk.JdkHttpClient +import dev.langchain4j.http.client.jdk.JdkHttpClientBuilder +import io.askimo.core.config.AppConfig +import io.askimo.core.providers.HttpVersion +import java.net.http.HttpClient +import java.time.Duration + +/** + * Creates a [JdkHttpClient] builder configured with the given HTTP version, proxy settings, + * and standard timeouts from [AppConfig]. + * + * Proxy is automatically bypassed for localhost/private-IP URLs when [baseUrl] is provided. + * Pass `null` for cloud providers (e.g. Anthropic, Gemini) where no local bypass is needed. + * + * This is the shared HTTP-client factory used by all model factories — both those that extend + * [io.askimo.core.providers.openaicompatible.OpenAiCompatibleChatModelFactory] and standalone factories + * (Anthropic, Gemini) that implement [io.askimo.core.providers.ChatModelFactory] directly. + */ +fun createJdkHttpClientBuilder( + baseUrl: String? = null, + httpVersion: HttpVersion = HttpVersion.HTTP_2, +): JdkHttpClientBuilder = JdkHttpClient.builder().httpClientBuilder( + ProxyUtil.configureProxy( + HttpClient.newBuilder().version(httpVersion.toJdkVersion()), + baseUrl, + ).withLoggingIfDebug(), +).readTimeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) + .connectTimeout(Duration.ofSeconds(AppConfig.models.timeouts.defaultModelTimeoutSeconds)) + +/** + * Maps the provider-agnostic [HttpVersion] enum to the JDK [HttpClient.Version] enum + * used by [java.net.http.HttpClient] and the LangChain4j JDK HTTP client. + */ +fun HttpVersion.toJdkVersion(): HttpClient.Version = when (this) { + HttpVersion.HTTP_1_1 -> HttpClient.Version.HTTP_1_1 + HttpVersion.HTTP_2 -> HttpClient.Version.HTTP_2 +} diff --git a/shared/src/main/kotlin/io/askimo/core/util/LoggingHttpClientBuilder.kt b/shared/src/main/kotlin/io/askimo/core/util/LoggingHttpClientBuilder.kt index bcc39633e..d827693d0 100644 --- a/shared/src/main/kotlin/io/askimo/core/util/LoggingHttpClientBuilder.kt +++ b/shared/src/main/kotlin/io/askimo/core/util/LoggingHttpClientBuilder.kt @@ -141,9 +141,14 @@ class LoggingHttpClient( bodyBytes: ByteArray, truncated: Boolean, ) { + val version = when (responseInfo.version()) { + Version.HTTP_1_1 -> "HTTP/1.1" + Version.HTTP_2 -> "HTTP/2" + else -> responseInfo.version().name + } val sb = StringBuilder() sb.appendLine("──── Incoming HTTP Response ────────────────────────────────────────────") - sb.appendLine("${responseInfo.statusCode()} ${request.method()} ${request.uri()}") + sb.appendLine("${responseInfo.statusCode()} ${request.method()} ${request.uri()} [$version]") sb.appendLine() sb.appendHeaders(responseInfo.headers()) if (bodyBytes.isNotEmpty()) { @@ -243,10 +248,18 @@ class LoggingHttpClient( } private fun logRequest(request: HttpRequest, body: String?) { - // No isDebugEnabled guard needed — this class is only instantiated when debug is on. + val version = request.version() + .map { v -> + when (v) { + Version.HTTP_1_1 -> "HTTP/1.1" + Version.HTTP_2 -> "HTTP/2" + else -> v.name + } + } + .orElse("default") val sb = StringBuilder() sb.appendLine("──── Outgoing HTTP Request ────────────────────────────────────────────") - sb.appendLine("${request.method()} ${request.uri()}") + sb.appendLine("${request.method()} ${request.uri()} [$version]") sb.appendLine() sb.appendHeaders(request.headers()) if (body != null) { diff --git a/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt b/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt deleted file mode 100644 index f6b2ff498..000000000 --- a/shared/src/main/kotlin/io/askimo/core/util/SystemPrompts.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* SPDX-License-Identifier: AGPLv3 - * - * Copyright (c) 2026 Askimo - */ -package io.askimo.core.util - -object SystemPrompts { - val markdownDefaults = - listOf( - "By default, always respond in GitHub-Flavored Markdown.", - ) - - fun systemMessage(vararg extras: String) = (markdownDefaults + extras).joinToString("\n\n") -}