Merge branch 'master' into AlexanderLuck/master

This commit is contained in:
Carl-Robert Linnupuu 2026-03-13 17:06:10 +00:00
commit dd97160163
5 changed files with 111 additions and 74 deletions

View file

@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [3.7.5-241.1] - 2026-03-12
### Added
- Mercury 2 model via the Inception provider
- Markdown table rendering in chat responses [#1186](https://github.com/carlrobertoh/ProxyAI/issues/1186)
- `workingDirectory` override support for the Bash tool
- Support for closing the first tool window tab [#865](https://github.com/carlrobertoh/ProxyAI/issues/865)
### Changed
- Refreshed the built-in model catalog and provider integrations
### Fixed
- Bash tool execution on Linux [#1189](https://github.com/carlrobertoh/ProxyAI/issues/1189)
- The credits label is now shown only for the ProxyAI provider
## [3.7.4-241.1] - 2026-02-18
### Added
@ -1282,7 +1300,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `OPENAI_API_KEY` persistence, key is saved in the OS password safe from now on
[Unreleased]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.4-241.1...HEAD
[Unreleased]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.5-241.1...HEAD
[3.7.5-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.4-241.1...v3.7.5-241.1
[3.7.4-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.3-241.1...v3.7.4-241.1
[3.7.3-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.2-241.1...v3.7.3-241.1
[3.7.2-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.1-241.1...v3.7.2-241.1

View file

@ -4,7 +4,7 @@ pluginGroup = ee.carlrobert
pluginName = ProxyAI
pluginRepositoryUrl = https://github.com/carlrobertoh/ProxyAI
# SemVer format -> https://semver.org
pluginVersion = 3.7.4
pluginVersion = 3.7.5
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 241.1

View file

@ -20,6 +20,8 @@ import ai.koog.prompt.params.LLMParams
import ai.koog.prompt.streaming.StreamFrame
import ee.carlrobert.codegpt.agent.AgentEvents
import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.IOException
import java.net.SocketTimeoutException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
@ -65,11 +67,29 @@ class RetryingPromptExecutor(
}
val causes = generateSequence(error) { it.cause }.take(10).toList()
if (causes.any { it.isRetryableTimeout() }) {
return true
}
val statusCode = causes
.filterIsInstance<KoogHttpClientException>()
.firstNotNullOfOrNull { it.statusCode }
return statusCode in RETRYABLE_HTTP_STATUS_CODES
}
private fun Throwable.isRetryableTimeout(): Boolean {
if (this is TimeoutCancellationException || this is SocketTimeoutException) {
return true
}
return this is IOException && hasTimeoutMessage()
}
private fun Throwable.hasTimeoutMessage(): Boolean {
val message = message ?: return false
return message.contains("timed out", ignoreCase = true)
|| message.contains("timeout", ignoreCase = true)
}
}
private class RetryStreamingRequestException(cause: Throwable) :

View file

@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.LocalDate
@ -62,7 +64,6 @@ IMPORTANT - Use the correct year in search queries:
hookManager = hookManager,
sessionId = sessionId,
) {
@Serializable
data class Args(
@property:LLMDescription(
@ -96,38 +97,19 @@ IMPORTANT - Use the correct year in search queries:
)
override suspend fun doExecute(args: Args): Result = withContext(Dispatchers.IO) {
try {
val searxResults = searchWithSearxNG(args.query)
if (searxResults.isNotEmpty()) {
val filteredResults =
filterResults(
searxResults,
args.allowedDomains,
args.blockedDomains
)
return@withContext Result(
query = args.query,
results = filteredResults.take(10),
sources = filteredResults.take(10).map { "[${it.title}](${it.url})" }
)
}
} catch (_: Exception) {
// Fall back to DuckDuckGo
}
try {
val duckduckgoResults = searchWithDuckDuckGo(args.query)
val filteredResults =
filterResults(
duckduckgoResults,
args.allowedDomains,
args.blockedDomains
)
return@withContext Result(
val filteredResults = filterResults(
duckduckgoResults,
args.allowedDomains,
args.blockedDomains
).take(10)
val result = Result(
query = args.query,
results = filteredResults.take(10),
sources = filteredResults.take(10).map { "[${it.title}](${it.url})" }
results = filteredResults,
sources = filteredResults.map { "[${it.title}](${it.url})" }
)
return@withContext result
} catch (_: Exception) {
return@withContext Result(
query = args.query,
@ -148,34 +130,6 @@ IMPORTANT - Use the correct year in search queries:
)
}
private suspend fun searchWithSearxNG(query: String): List<SearchResult> =
withContext(Dispatchers.IO) {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
val url = "https://searx.space/search?q=$encodedQuery&format=json"
val doc = Jsoup.connect(url)
.userAgent(userAgent)
.timeout(10000)
.ignoreContentType(true)
.get()
val jsonResponse = doc.body().text()
if (jsonResponse.isEmpty()) return@withContext emptyList()
try {
val searxResponse = json.decodeFromString<SearxResponse>(jsonResponse)
searxResponse.results.map { result ->
SearchResult(
title = result.title,
url = result.url,
content = result.content
)
}
} catch (_: Exception) {
emptyList()
}
}
private suspend fun searchWithDuckDuckGo(query: String): List<SearchResult> =
withContext(Dispatchers.IO) {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
@ -193,10 +147,11 @@ IMPORTANT - Use the correct year in search queries:
val snippet = resultDiv.selectFirst("a.result__snippet")
if (titleLink != null) {
val resolvedUrl = normalizeSearchResultUrl(titleLink.attr("href"))
results.add(
SearchResult(
title = titleLink.text(),
url = titleLink.attr("href"),
url = resolvedUrl,
content = snippet?.text() ?: ""
)
)
@ -211,14 +166,16 @@ IMPORTANT - Use the correct year in search queries:
allowedDomains: List<String>?,
blockedDomains: List<String>?
): List<SearchResult> {
val effectiveAllowedDomains = normalizeDomains(allowedDomains)
val effectiveBlockedDomains = normalizeDomains(blockedDomains)
return results.filter { result ->
val urlLower = result.url.lowercase()
blockedDomains?.any { domain ->
effectiveBlockedDomains?.any { domain ->
urlLower.contains(domain.lowercase())
}?.let { if (it) return@filter false }
allowedDomains?.let { allowed ->
effectiveAllowedDomains?.let { allowed ->
allowed.any { domain ->
urlLower.contains(domain.lowercase())
}
@ -226,6 +183,44 @@ IMPORTANT - Use the correct year in search queries:
}
}
companion object {
internal fun normalizeSearchResultUrl(url: String): String {
if (url.isBlank()) return url
val absoluteUrl = if (url.startsWith("//")) {
"https:$url"
} else {
url
}
return runCatching {
val uri = URI(absoluteUrl)
val host = uri.host?.lowercase()
if (host != "duckduckgo.com" && host != "www.duckduckgo.com") {
return absoluteUrl
}
val query = uri.rawQuery ?: return absoluteUrl
query.split("&")
.firstNotNullOfOrNull { segment ->
val idx = segment.indexOf('=')
if (idx <= 0) return@firstNotNullOfOrNull null
val key = segment.substring(0, idx)
if (key != "uddg") return@firstNotNullOfOrNull null
URLDecoder.decode(segment.substring(idx + 1), StandardCharsets.UTF_8)
}
?: absoluteUrl
}.getOrDefault(absoluteUrl)
}
internal fun normalizeDomains(domains: List<String>?): List<String>? {
return domains
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
}
}
override fun encodeResultToString(result: Result): String =
buildString {
if (result.results.isEmpty()) {
@ -243,16 +238,4 @@ IMPORTANT - Use the correct year in search queries:
appendLine()
appendLine("*Click on any result to view the full content*")
}.trimEnd().truncateToolResult()
@Serializable
private data class SearxResponse(
val results: List<SearxResult>
)
@Serializable
private data class SearxResult(
val title: String,
val url: String,
val content: String
)
}

View file

@ -3,6 +3,7 @@ package ee.carlrobert.codegpt.agent.clients
import ai.koog.http.client.KoogHttpClientException
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import java.io.IOException
class RetryingPromptExecutorRetryabilityTest {
@ -33,6 +34,20 @@ class RetryingPromptExecutorRetryabilityTest {
assertThat(RetryingPromptExecutor.isRetryableFailure(nested)).isTrue()
}
@Test
fun `should retry when transport timeout is in nested cause chain`() {
val timeout = IOException("Operation timed out")
val nested = KoogHttpClientException(
"Error from client: InceptionAILLMClient\nMessage: Operation timed out",
null,
null,
null,
RuntimeException("sse wrapper", timeout)
)
assertThat(RetryingPromptExecutor.isRetryableFailure(nested)).isTrue()
}
@Test
fun `should not retry when no HTTP status is present`() {
assertThat(RetryingPromptExecutor.isRetryableFailure(RuntimeException("connection reset"))).isFalse()