fix(relay): audit fixes — abort signal, model selector guard, SSE CRLF, SQL format
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Addresses findings from deep code audit:
H-1: Pass abortController.signal to saasClient.chatCompletion() so
user-cancelled streams actually abort the HTTP connection (was only
stopping the read loop, leaving server-side SSE connection open).
H-2: ModelSelector now shows only when (!isTauriRuntime() || isLoggedIn).
Prevents decorative model list in Tauri local kernel mode where model
selection has no effect (violates CLAUDE.md §5.2).
M-1: Normalize CRLF to LF before SSE event boundary parsing (\n\n).
Prevents buffer overflow when behind nginx/CDN with CRLF line endings.
M-2: SQL window_minute comparison uses to_char(NOW()-interval, format)
instead of (NOW()-interval)::TEXT, matching the stored format exactly.
M-3: sort_candidates_by_quota uses same sliding 60s window as select_best_key.
LOW: Fix misleading invalidate_cache doc comment.
This commit is contained in:
@@ -28,7 +28,7 @@ fn get_cache() -> &'static DashMap<String, CachedSelection> {
|
|||||||
KEY_SELECTION_CACHE.get_or_init(DashMap::new)
|
KEY_SELECTION_CACHE.get_or_init(DashMap::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate cached selection for a provider (called on usage record and 429 marking)
|
/// Invalidate cached selection for a provider (called on 429 marking)
|
||||||
fn invalidate_cache(provider_id: &str) {
|
fn invalidate_cache(provider_id: &str) {
|
||||||
let cache = get_cache();
|
let cache = get_cache();
|
||||||
cache.remove(provider_id);
|
cache.remove(provider_id);
|
||||||
@@ -86,7 +86,7 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
|
|||||||
COALESCE(SUM(uw.token_count), 0)
|
COALESCE(SUM(uw.token_count), 0)
|
||||||
FROM provider_keys pk
|
FROM provider_keys pk
|
||||||
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id
|
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id
|
||||||
AND uw.window_minute >= (NOW() - INTERVAL '1 minute')::TEXT
|
AND uw.window_minute >= to_char(NOW() - INTERVAL '1 minute', 'YYYY-MM-DDTHH24:MI')
|
||||||
WHERE pk.provider_id = $1 AND pk.is_active = TRUE
|
WHERE pk.provider_id = $1 AND pk.is_active = TRUE
|
||||||
AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= $2)
|
AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= $2)
|
||||||
GROUP BY pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm
|
GROUP BY pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm
|
||||||
@@ -200,7 +200,7 @@ pub async fn record_key_usage(
|
|||||||
|
|
||||||
// 3. 清理过期的滑动窗口行(保留最近 2 分钟即可)
|
// 3. 清理过期的滑动窗口行(保留最近 2 分钟即可)
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"DELETE FROM key_usage_window WHERE window_minute < (NOW() - INTERVAL '2 minutes')::TEXT"
|
"DELETE FROM key_usage_window WHERE window_minute < to_char(NOW() - INTERVAL '2 minutes', 'YYYY-MM-DDTHH24:MI')"
|
||||||
)
|
)
|
||||||
.execute(db).await; // 忽略错误,非关键操作
|
.execute(db).await; // 忽略错误,非关键操作
|
||||||
|
|
||||||
|
|||||||
@@ -651,7 +651,7 @@ pub async fn sort_candidates_by_quota(
|
|||||||
SUM(COALESCE(pk.max_rpm, 999999) - COALESCE(uw.request_count, 0)) AS remaining_rpm
|
SUM(COALESCE(pk.max_rpm, 999999) - COALESCE(uw.request_count, 0)) AS remaining_rpm
|
||||||
FROM provider_keys pk
|
FROM provider_keys pk
|
||||||
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id
|
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id
|
||||||
AND uw.window_minute = to_char(date_trunc('minute', NOW()), 'YYYY-MM-DDTHH24:MI')
|
AND uw.window_minute >= to_char(NOW() - INTERVAL '1 minute', 'YYYY-MM-DDTHH24:MI')
|
||||||
WHERE pk.provider_id = ANY($1)
|
WHERE pk.provider_id = ANY($1)
|
||||||
AND pk.is_active = TRUE
|
AND pk.is_active = TRUE
|
||||||
AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= NOW())
|
AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= NOW())
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ReasoningBlock } from './ai/ReasoningBlock';
|
|||||||
import { StreamingText } from './ai/StreamingText';
|
import { StreamingText } from './ai/StreamingText';
|
||||||
import { ChatMode } from './ai/ChatMode';
|
import { ChatMode } from './ai/ChatMode';
|
||||||
import { ModelSelector } from './ai/ModelSelector';
|
import { ModelSelector } from './ai/ModelSelector';
|
||||||
|
import { isTauriRuntime } from '../lib/tauri-gateway';
|
||||||
import { SuggestionChips } from './ai/SuggestionChips';
|
import { SuggestionChips } from './ai/SuggestionChips';
|
||||||
import { PipelineResultPreview } from './pipeline/PipelineResultPreview';
|
import { PipelineResultPreview } from './pipeline/PipelineResultPreview';
|
||||||
import { PresentationContainer } from './presentation/PresentationContainer';
|
import { PresentationContainer } from './presentation/PresentationContainer';
|
||||||
@@ -562,7 +563,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{models.length > 0 && (
|
{models.length > 0 && (!isTauriRuntime() || isLoggedIn) && (
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
models={models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))}
|
models={models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))}
|
||||||
currentModel={currentModel}
|
currentModel={currentModel}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function createSaaSRelayGatewayClient(
|
|||||||
if (opts?.plan_mode) body['plan_mode'] = true;
|
if (opts?.plan_mode) body['plan_mode'] = true;
|
||||||
if (opts?.subagent_enabled) body['subagent_enabled'] = true;
|
if (opts?.subagent_enabled) body['subagent_enabled'] = true;
|
||||||
|
|
||||||
const response = await saasClient.chatCompletion(body);
|
const response = await saasClient.chatCompletion(body, abortController.signal);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errText = await response.text().catch(() => '');
|
const errText = await response.text().catch(() => '');
|
||||||
@@ -160,6 +160,9 @@ export function createSaaSRelayGatewayClient(
|
|||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Normalize CRLF to LF for SSE spec compliance
|
||||||
|
buffer = buffer.replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
// Optimized SSE parsing: split by double-newline (event boundaries)
|
// Optimized SSE parsing: split by double-newline (event boundaries)
|
||||||
let boundary: number;
|
let boundary: number;
|
||||||
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
||||||
|
|||||||
Reference in New Issue
Block a user