mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 07:54:46 +00:00
Merge branch 'master' into AlexanderLuck/master
This commit is contained in:
commit
dd97160163
5 changed files with 111 additions and 74 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) :
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue