Skip to content

Commit 914b3c0

Browse files
authored
Release 0.4.1 (#22)
* fix: update Kotlin compile options and improve request validation logic * fix: enhance API key management and SSE format compliance * fix: improve SSE format compliance and reduce logging noise
1 parent 1e6cb7e commit 914b3c0

File tree

21 files changed

+860
-32
lines changed

21 files changed

+860
-32
lines changed

.github/workflows/release.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,12 @@ jobs:
158158
publish-to-marketplace:
159159
needs: [validate-version, build-and-test, create-release]
160160
runs-on: ubuntu-latest
161+
permissions:
162+
contents: write # Required to update releases with marketplace link
161163
if: |
162164
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
163165
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_to_marketplace == 'true')
164-
166+
165167
steps:
166168
- name: Checkout code
167169
uses: actions/checkout@v4

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,36 @@ All notable changes to the OpenRouter IntelliJ Plugin will be documented in this
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.1] - 2025-12-23
9+
10+
### Bug Fixes
11+
12+
#### 🔧 SSE Streaming Format Compliance
13+
- **Fixed AI Assistant Streaming** - Fixed SSE (Server-Sent Events) format to comply with specification
14+
- **Blank Line Separators** - Added required blank lines after each SSE event to prevent JSON concatenation
15+
- **Stream Termination** - Ensured all streams end with `[DONE]` marker followed by blank line
16+
- **Error Handling** - Fixed error response SSE format to include proper event separators
17+
18+
**Root Cause**: SSE events were not separated by blank lines, causing the AI Assistant's JSON parser to receive concatenated JSON objects like `{"id":"1",...}{"id":"2",...}`, resulting in parsing error: "Expected EOF after parsing, but had { instead"
19+
20+
**Fix**: Added `writer.println()` after each SSE event in `StreamingResponseHandler.processStreamLine()` and error handlers to create the required blank line separator per SSE specification.
21+
22+
### Code Quality
23+
24+
#### 📝 Logging Improvements
25+
- **Reduced Log Noise** - Moved verbose logging (getApiKey, getStoredApiKey, API key validation) from INFO to DEBUG level
26+
- **Standardized Request IDs** - All log lines now use consistent `[Chat-XXXXXX]` format for easier log correlation
27+
- **Fixed Log Levels** - Changed normal API request logging from WARN to DEBUG level
28+
- **Request Duration Metrics** - Added request duration tracking to completion logs (e.g., `[Chat-000025] REQUEST COMPLETE (2118ms)`)
29+
30+
**Impact**: 78% reduction in INFO-level log volume, better performance visibility, no false warnings
31+
32+
#### 🧪 Testing
33+
- **SSE Format Tests** - Added 11 tests for SSE format compliance
34+
- **Regression Tests** - Added specific tests documenting the SSE parsing bug and fix
35+
- **AI Assistant Compatibility** - Tests verify no JSON concatenation and proper event separation
36+
- **Error Response Tests** - Tests ensure error responses follow SSE format with DONE marker
37+
838
## [0.4.0] - 2025-12-22
939

1040
### New Features

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# OpenRouter IntelliJ Plugin
22

33
[![JetBrains Plugin](https://img.shields.io/badge/JetBrains-Plugin-orange.svg)](https://plugins.jetbrains.com/plugin/28520)
4-
[![Version](https://img.shields.io/badge/version-0.4.0-blue.svg)](https://github.com/DimazzzZ/openrouter-intellij-plugin/releases)
4+
[![Version](https://img.shields.io/badge/version-0.4.1-blue.svg)](https://github.com/DimazzzZ/openrouter-intellij-plugin/releases)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
66

77
An IntelliJ IDEA plugin for integrating with [OpenRouter.ai](https://openrouter.ai), providing access to 400+ AI models with usage monitoring, quota tracking, and seamless JetBrains AI Assistant integration.

build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import org.jetbrains.intellij.tasks.PublishPluginTask
2+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
23

34
plugins {
45
id("java")
@@ -99,7 +100,7 @@ tasks {
99100
targetCompatibility = "17"
100101
}
101102
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
102-
kotlinOptions.jvmTarget = "17"
103+
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
103104
}
104105

105106
// Configure Detekt tasks

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
pluginGroup = org.zhavoronkov
44
pluginName = OpenRouter
55
pluginRepositoryUrl = https://github.com/DimazzzZ/openrouter-intellij-plugin
6-
pluginVersion = 0.4.0
6+
pluginVersion = 0.4.1
77

88
# Plugin metadata
99
pluginId = org.zhavoronkov.openrouter

src/main/kotlin/org/zhavoronkov/openrouter/proxy/servlets/ChatCompletionServlet.kt

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,28 @@ class ChatCompletionServlet : HttpServlet() {
9898
} catch (e: RuntimeException) {
9999
handleException(e, resp, requestId)
100100
} finally {
101-
logRequestBoundary(requestId, isStart = false)
101+
val durationMs = (System.nanoTime() - startNs) / 1_000_000
102+
logRequestBoundary(requestId, isStart = false, durationMs = durationMs)
102103
}
103104
}
104105

105106
/**
106-
* Log request start
107+
* Log request start/completion with optional duration
107108
*/
108-
private fun logRequestBoundary(requestId: String, isStart: Boolean, requestNumber: Int = 0) {
109+
private fun logRequestBoundary(
110+
requestId: String,
111+
isStart: Boolean,
112+
requestNumber: Int = 0,
113+
durationMs: Long = 0
114+
) {
109115
PluginLogger.Service.info("═══════════════════════════════════════════════════════")
110116
if (isStart) {
111117
val timestamp = System.currentTimeMillis()
112118
PluginLogger.Service.info("[Chat-$requestId] NEW CHAT COMPLETION REQUEST RECEIVED")
113119
PluginLogger.Service.info("[Chat-$requestId] Timestamp: $timestamp")
114120
PluginLogger.Service.info("[Chat-$requestId] Total chat requests so far: $requestNumber")
115121
} else {
116-
PluginLogger.Service.info("[Chat-$requestId] REQUEST COMPLETE")
122+
PluginLogger.Service.info("[Chat-$requestId] REQUEST COMPLETE (${durationMs}ms)")
117123
}
118124
PluginLogger.Service.info("═══════════════════════════════════════════════════════")
119125
}
@@ -335,7 +341,10 @@ class ChatCompletionServlet : HttpServlet() {
335341
// Create user-friendly error message
336342
val userFriendlyMessage = createUserFriendlyErrorMessage(errorBody, context.response.code)
337343

344+
// Write error event with proper SSE format (data line + blank line)
338345
context.writer.write("data: ${gson.toJson(mapOf("error" to mapOf("message" to userFriendlyMessage)))}\n\n")
346+
// Write [DONE] event to signal end of stream
347+
context.writer.write("data: [DONE]\n\n")
339348
context.writer.flush()
340349
}
341350

@@ -451,7 +460,7 @@ class ChatCompletionServlet : HttpServlet() {
451460
return try {
452461
val openAIRequest = gson.fromJson(requestBody, OpenAIChatCompletionRequest::class.java)
453462

454-
if (openAIRequest.messages.isNullOrEmpty()) {
463+
if (openAIRequest.messages.isEmpty()) {
455464
PluginLogger.Service.error(
456465
"[Chat-$requestId] Request validation failed: messages cannot be null or empty"
457466
)

src/main/kotlin/org/zhavoronkov/openrouter/proxy/servlets/RequestValidator.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class RequestValidator(
2222
val apiKey = settingsService.apiKeyManager.getStoredApiKey()
2323

2424
if (apiKey.isNullOrBlank()) {
25-
PluginLogger.Service.error("[$requestId] No API key configured")
25+
PluginLogger.Service.error("[Chat-$requestId] No API key configured")
2626
resp.status = HttpServletResponse.SC_UNAUTHORIZED
2727
resp.contentType = "application/json"
2828
val errorMessage = "API key not configured. " +
@@ -39,7 +39,7 @@ class RequestValidator(
3939
return null
4040
}
4141

42-
PluginLogger.Service.debug("[$requestId] API key validated successfully")
42+
PluginLogger.Service.debug("[Chat-$requestId] API key validated successfully")
4343
return apiKey
4444
}
4545

src/main/kotlin/org/zhavoronkov/openrouter/proxy/servlets/StreamingResponseHandler.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class StreamingResponseHandler {
4545
writer.println("data: [DONE]")
4646
writer.println()
4747
writer.flush()
48-
PluginLogger.Service.debug("[$requestId] Streaming completed successfully")
48+
PluginLogger.Service.debug("[Chat-$requestId] Streaming completed successfully")
4949
}
5050

5151
private fun processStreamLine(line: String, writer: PrintWriter): Boolean {
@@ -54,17 +54,22 @@ class StreamingResponseHandler {
5454
if (data == "[DONE]") {
5555
return true
5656
}
57+
// SSE format requires: "data: <content>\n\n" (blank line after each event)
58+
// println() adds one newline, then we add another for the blank line
5759
writer.println(line)
60+
writer.println() // Required blank line to separate SSE events
5861
writer.flush()
5962
}
6063
return false
6164
}
6265

6366
fun handleStreamingError(e: Exception, writer: PrintWriter, requestId: String) {
64-
PluginLogger.Service.error("[$requestId] Error during streaming", e)
67+
PluginLogger.Service.error("[Chat-$requestId] Error during streaming", e)
6568
val errorJson = """{"error": {"message": "Streaming error: ${e.message}", "type": "stream_error"}}"""
6669
writer.println("data: $errorJson")
70+
writer.println() // Blank line to separate SSE events
6771
writer.println("data: [DONE]")
72+
writer.println() // Blank line after final event
6873
writer.flush()
6974
}
7075

@@ -77,7 +82,7 @@ class StreamingResponseHandler {
7782
fun handleStreamingErrorResponse(context: StreamingErrorContext) {
7883
val errorBody = context.response.body?.string() ?: "Unknown error"
7984
PluginLogger.Service.error(
80-
"[${context.requestId}] OpenRouter streaming request failed: " +
85+
"[Chat-${context.requestId}] OpenRouter streaming request failed: " +
8186
"status=${context.response.code}, body=$errorBody"
8287
)
8388

@@ -98,7 +103,9 @@ class StreamingResponseHandler {
98103
""".trimIndent().replace("\n", "")
99104

100105
writer.println("data: $errorJson")
106+
writer.println() // Blank line to separate SSE events
101107
writer.println("data: [DONE]")
108+
writer.println() // Blank line after final event
102109
writer.flush()
103110
}
104111

src/main/kotlin/org/zhavoronkov/openrouter/services/settings/ApiKeySettingsManager.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class ApiKeySettingsManager(
2828
} else {
2929
encrypted
3030
}
31-
PluginLogger.Service.info(
31+
PluginLogger.Service.debug(
3232
"getApiKey: encrypted.length=${encrypted.length}, encrypted.isEmpty=${encrypted.isEmpty()}, " +
3333
"decrypted.length=${decrypted.length}, decrypted.isEmpty=${decrypted.isEmpty()}"
3434
)
@@ -67,8 +67,30 @@ class ApiKeySettingsManager(
6767
)
6868
}
6969

70+
/**
71+
* Gets the API key to use for chat completions.
72+
*
73+
* In REGULAR mode: Returns the user-provided API key from settings.apiKey
74+
* In EXTENDED mode: Returns the auto-created "IntelliJ IDEA Plugin" API key from settings.apiKey
75+
*
76+
* Note: Both modes use settings.apiKey, but the key source differs:
77+
* - REGULAR: User manually enters their API key
78+
* - EXTENDED: Plugin auto-creates a key via provisioning key and stores it
79+
*
80+
* @return The API key to use, or null if not configured
81+
*/
7082
fun getStoredApiKey(): String? {
83+
// In EXTENDED mode, we need to ensure the stored API key is valid
84+
// The IntellijApiKeyManager.ensureIntellijApiKeyExists() handles this
85+
// by validating and regenerating the key if needed
7186
val apiKey = getApiKey()
87+
88+
// Log which mode we're in for debugging
89+
PluginLogger.Service.debug(
90+
"getStoredApiKey: authScope=${settings.authScope}, " +
91+
"apiKey.length=${apiKey.length}, apiKey.isNotBlank=${apiKey.isNotBlank()}"
92+
)
93+
7294
return if (apiKey.isNotBlank()) apiKey else null
7395
}
7496

src/main/kotlin/org/zhavoronkov/openrouter/settings/IntellijApiKeyManager.kt

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,42 @@ class IntellijApiKeyManager(
3737
"ensureIntellijApiKeyExists: existingIntellijApiKey=${existingIntellijApiKey?.name ?: "null"}"
3838
)
3939

40-
if (existingIntellijApiKey != null && storedApiKey.isNotEmpty()) {
41-
PluginLogger.Settings.debug("IntelliJ API key exists and is stored locally - no action needed")
40+
// Validate that the stored API key actually matches the remote IntelliJ key
41+
// The label field contains a preview of the key (e.g., "sk-or-v1-abc...xyz")
42+
// We compare the prefix before "..." with the stored key's prefix
43+
val storedKeyMatchesRemote = if (existingIntellijApiKey != null && storedApiKey.isNotEmpty()) {
44+
val keyLabel = existingIntellijApiKey.label
45+
// Label format is like "sk-or-v1-abc...xyz" - extract the prefix to compare
46+
val labelPrefix = if (keyLabel.contains("...")) {
47+
keyLabel.substringBefore("...")
48+
} else {
49+
// If no "...", use the whole label as prefix (shouldn't happen normally)
50+
keyLabel
51+
}
52+
val storedKeyPrefix = storedApiKey.take(labelPrefix.length)
53+
val matches = storedKeyPrefix == labelPrefix
54+
PluginLogger.Settings.debug(
55+
"ensureIntellijApiKeyExists: keyLabel=$keyLabel, " +
56+
"labelPrefix=$labelPrefix, storedKeyPrefix=$storedKeyPrefix, matches=$matches"
57+
)
58+
matches
59+
} else {
60+
false
61+
}
62+
63+
if (existingIntellijApiKey != null && storedApiKey.isNotEmpty() && storedKeyMatchesRemote) {
64+
PluginLogger.Settings.debug("IntelliJ API key exists and stored key matches remote - no action needed")
4265
return
4366
}
4467

45-
if (existingIntellijApiKey == null && !isCreatingApiKey) {
68+
if (existingIntellijApiKey != null && storedApiKey.isNotEmpty() && !storedKeyMatchesRemote) {
69+
PluginLogger.Settings.warn(
70+
"Stored API key does not match remote IntelliJ API key - regenerating"
71+
)
72+
if (!isCreatingApiKey) {
73+
recreateIntellijApiKeySilently()
74+
}
75+
} else if (existingIntellijApiKey == null && !isCreatingApiKey) {
4676
PluginLogger.Settings.info("IntelliJ API key not found, creating automatically")
4777
createIntellijApiKeyOnce()
4878
} else if (existingIntellijApiKey != null && storedApiKey.isEmpty() && !isCreatingApiKey) {

0 commit comments

Comments
 (0)