diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 5de2ac345..75886851d 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -32,6 +32,10 @@ export class AiSdkToChunkAdapter { private firstTokenTimestamp: number | null = null private hasTextContent = false private getSessionWasCleared?: () => boolean + private idleTimeoutMs?: number + private idleAbortController?: AbortController + private idleTimeoutTimer: ReturnType | null = null + private idleTimeoutTriggered = false constructor( private onChunk: (chunk: Chunk) => void, @@ -39,13 +43,19 @@ export class AiSdkToChunkAdapter { accumulate?: boolean, enableWebSearch?: boolean, onSessionUpdate?: (sessionId: string) => void, - getSessionWasCleared?: () => boolean + getSessionWasCleared?: () => boolean, + streamingConfig?: { + idleTimeoutMs: number + idleAbortController: AbortController + } ) { this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools) this.accumulate = accumulate this.enableWebSearch = enableWebSearch || false this.onSessionUpdate = onSessionUpdate this.getSessionWasCleared = getSessionWasCleared + this.idleTimeoutMs = streamingConfig?.idleTimeoutMs + this.idleAbortController = streamingConfig?.idleAbortController } private markFirstTokenIfNeeded() { @@ -59,6 +69,27 @@ export class AiSdkToChunkAdapter { this.firstTokenTimestamp = null } + private clearIdleTimeoutTimer() { + if (this.idleTimeoutTimer) { + clearTimeout(this.idleTimeoutTimer) + this.idleTimeoutTimer = null + } + } + + private resetIdleTimeoutTimer() { + if (!this.idleTimeoutMs || this.idleTimeoutMs <= 0 || !this.idleAbortController) { + return + } + + this.clearIdleTimeoutTimer() + + this.idleTimeoutTimer = setTimeout(() => { + this.idleTimeoutTriggered = true + logger.warn('SSE idle timeout reached; aborting request', { idleTimeoutMs: this.idleTimeoutMs }) + this.idleAbortController?.abort() + }, this.idleTimeoutMs) + } + /** * 处理 AI SDK 流结果 * @param aiSdkResult AI SDK 的流结果对象 @@ -88,6 +119,8 @@ export class AiSdkToChunkAdapter { } this.resetTimingState() this.responseStartTimestamp = Date.now() + this.idleTimeoutTriggered = false + this.resetIdleTimeoutTimer() // Reset state at the start of stream this.isFirstChunk = true this.hasTextContent = false @@ -111,10 +144,12 @@ export class AiSdkToChunkAdapter { break } + this.resetIdleTimeoutTimer() // 转换并发送 chunk this.convertAndEmitChunk(value, final) } } finally { + this.clearIdleTimeoutTimer() reader.releaseLock() this.resetTimingState() } @@ -380,7 +415,12 @@ export class AiSdkToChunkAdapter { case 'abort': this.onChunk({ type: ChunkType.ERROR, - error: new DOMException('Request was aborted', 'AbortError') + error: this.idleTimeoutTriggered + ? new DOMException( + `SSE idle timeout after ${Math.round((this.idleTimeoutMs ?? 0) / 60000)} minutes`, + 'TimeoutError' + ) + : new DOMException('Request was aborted', 'AbortError') }) break case 'error': diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 5c84a7254..d07ac5365 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -42,6 +42,10 @@ export type ModernAiProviderConfig = AiSdkMiddlewareConfig & { // topicId for tracing topicId?: string callType: string + streamingConfig?: { + idleTimeoutMs: number + idleAbortController: AbortController + } } export default class ModernAiProvider { @@ -330,7 +334,15 @@ export default class ModernAiProvider { // 创建带有中间件的执行器 if (config.onChunk) { const accumulate = this.model!.supported_text_delta !== false // true and undefined - const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate, config.enableWebSearch) + const adapter = new AiSdkToChunkAdapter( + config.onChunk, + config.mcpTools ?? [], + accumulate, + config.enableWebSearch, + undefined, + undefined, + config.streamingConfig + ) const streamResult = await executor.streamText({ ...params, diff --git a/src/renderer/src/aiCore/prepareParams/__tests__/parameterBuilder.test.ts b/src/renderer/src/aiCore/prepareParams/__tests__/parameterBuilder.test.ts new file mode 100644 index 000000000..6e3569834 --- /dev/null +++ b/src/renderer/src/aiCore/prepareParams/__tests__/parameterBuilder.test.ts @@ -0,0 +1,161 @@ +import type { Assistant, Model, Provider } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@renderer/services/AssistantService', () => ({ + DEFAULT_ASSISTANT_SETTINGS: { + temperature: 0.7, + enableTemperature: true, + contextCount: 5, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + enableTopP: false, + toolUseMode: 'function', + customParameters: [] + }, + getDefaultAssistant: vi.fn(() => ({ + id: 'default', + name: 'Default Assistant', + prompt: '', + type: 'assistant', + topics: [], + settings: { + temperature: 0.7, + enableTemperature: true, + contextCount: 5, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + enableTopP: false, + toolUseMode: 'function', + customParameters: [] + } + })), + getDefaultModel: vi.fn(() => ({ + id: 'gpt-4o', + provider: 'openai', + name: 'GPT-4o', + group: 'openai' + })), + getAssistantSettings: vi.fn((assistant: any) => assistant?.settings ?? {}), + getProviderByModel: vi.fn(() => ({ + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: '', + apiHost: 'https://example.com/v1', + models: [] + })), + getDefaultTopic: vi.fn(() => ({ + id: 'topic-1', + assistantId: 'default', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + name: 'Default Topic', + messages: [], + isNameManuallyEdited: false + })) +})) + +vi.mock('@renderer/store', () => ({ + default: { + getState: vi.fn(() => ({ + websearch: { + maxResults: 5, + excludeDomains: [], + searchWithTime: false + } + })) + } +})) + +vi.mock('@renderer/utils/prompt', () => ({ + replacePromptVariables: vi.fn(async (prompt: string) => prompt) +})) + +vi.mock('../../utils/mcp', () => ({ + setupToolsConfig: vi.fn(() => undefined) +})) + +vi.mock('../../utils/options', () => ({ + buildProviderOptions: vi.fn(() => ({ + providerOptions: {}, + standardParams: {} + })) +})) + +import { buildStreamTextParams } from '../parameterBuilder' + +const createModel = (): Model => ({ + id: 'gpt-4o', + provider: 'openai', + name: 'GPT-4o', + group: 'openai' +}) + +const createAssistant = (model: Model): Assistant => ({ + id: 'assistant-1', + name: 'Assistant', + prompt: '', + type: 'assistant', + topics: [], + model, + settings: {} +}) + +const createProvider = (model: Model, overrides: Partial = {}): Provider => ({ + id: 'openai-response', + type: 'openai-response', + name: 'OpenAI Responses', + apiKey: 'test', + apiHost: 'https://example.com/v1', + models: [model], + ...overrides +}) + +describe('parameterBuilder.buildStreamTextParams', () => { + it('uses default max tool steps when unset', async () => { + const model = createModel() + const assistant = createAssistant(model) + const provider = createProvider(model) + + const { params } = await buildStreamTextParams([], assistant, provider, {}) + const stopWhen = params.stopWhen as any + + expect(stopWhen({ steps: new Array(19) })).toBe(false) + expect(stopWhen({ steps: new Array(20) })).toBe(true) + }) + + it('uses provider.maxToolSteps when set', async () => { + const model = createModel() + const assistant = createAssistant(model) + const provider = createProvider(model, { maxToolSteps: 42 }) + + const { params } = await buildStreamTextParams([], assistant, provider, {}) + const stopWhen = params.stopWhen as any + + expect(stopWhen({ steps: new Array(41) })).toBe(false) + expect(stopWhen({ steps: new Array(42) })).toBe(true) + }) + + it('returns streamingConfig and abortSignal when SSE idle timeout is enabled', async () => { + const model = createModel() + const assistant = createAssistant(model) + const provider = createProvider(model, { sseIdleTimeoutMinutes: 10 }) + + const userAbortController = new AbortController() + + const { params, streamingConfig } = await buildStreamTextParams([], assistant, provider, { + requestOptions: { signal: userAbortController.signal } + }) + + expect(streamingConfig?.idleTimeoutMs).toBe(10 * 60 * 1000) + expect(streamingConfig?.idleAbortController).toBeInstanceOf(AbortController) + expect(params.abortSignal).toBeDefined() + + userAbortController.abort() + expect(params.abortSignal?.aborted).toBe(true) + }) +}) diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index cba7fcdb1..adccd5e9a 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -40,6 +40,7 @@ import { stepCountIs } from 'ai' import { getAiSdkProviderId } from '../provider/factory' import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' +import { buildCombinedAbortSignal, normalizeMaxToolSteps, timeoutMinutesToMs } from '../utils/streamingTimeout' import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' import { addAnthropicHeaders } from './header' import { getMaxTokens, getTemperature, getTopP } from './modelParameters' @@ -95,6 +96,10 @@ export async function buildStreamTextParams( enableUrlContext: boolean } webSearchPluginConfig?: WebSearchPluginConfig + streamingConfig?: { + idleTimeoutMs: number + idleAbortController: AbortController + } }> { const { mcpTools } = options @@ -218,6 +223,17 @@ export async function buildStreamTextParams( // Note: standardParams (topK, frequencyPenalty, presencePenalty, stopSequences, seed) // are extracted from custom parameters and passed directly to streamText() // instead of being placed in providerOptions + const requestTimeoutMs = timeoutMinutesToMs(provider.requestTimeoutMinutes) + const idleTimeoutMs = timeoutMinutesToMs(provider.sseIdleTimeoutMinutes) + const idleAbortController = idleTimeoutMs ? new AbortController() : undefined + + const abortSignal = buildCombinedAbortSignal([ + options.requestOptions?.signal, + requestTimeoutMs ? AbortSignal.timeout(requestTimeoutMs) : undefined, + idleAbortController?.signal + ]) + + const maxToolSteps = normalizeMaxToolSteps(provider.maxToolSteps) const params: StreamTextParams = { messages: sdkMessages, maxOutputTokens: getMaxTokens(assistant, model), @@ -225,10 +241,10 @@ export async function buildStreamTextParams( topP: getTopP(assistant, model), // Include AI SDK standard params extracted from custom parameters ...standardParams, - abortSignal: options.requestOptions?.signal, + abortSignal, headers, providerOptions, - stopWhen: stepCountIs(20), + stopWhen: stepCountIs(maxToolSteps), maxRetries: 0 } @@ -246,7 +262,14 @@ export async function buildStreamTextParams( params, modelId: model.id, capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext }, - webSearchPluginConfig + webSearchPluginConfig, + streamingConfig: + idleTimeoutMs && idleAbortController + ? { + idleTimeoutMs, + idleAbortController + } + : undefined } } diff --git a/src/renderer/src/aiCore/utils/__tests__/streamingTimeout.test.ts b/src/renderer/src/aiCore/utils/__tests__/streamingTimeout.test.ts new file mode 100644 index 000000000..a27c527b6 --- /dev/null +++ b/src/renderer/src/aiCore/utils/__tests__/streamingTimeout.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { + buildCombinedAbortSignal, + normalizeMaxToolSteps, + normalizeTimeoutMinutes, + timeoutMinutesToMs +} from '../streamingTimeout' + +describe('streamingTimeout utils', () => { + it('normalizeTimeoutMinutes returns undefined for non-numbers', () => { + expect(normalizeTimeoutMinutes(undefined)).toBeUndefined() + expect(normalizeTimeoutMinutes(null)).toBeUndefined() + expect(normalizeTimeoutMinutes('10')).toBeUndefined() + }) + + it('normalizeTimeoutMinutes clamps to integer >= 0', () => { + expect(normalizeTimeoutMinutes(-1)).toBe(0) + expect(normalizeTimeoutMinutes(0)).toBe(0) + expect(normalizeTimeoutMinutes(1.9)).toBe(1) + }) + + it('timeoutMinutesToMs returns undefined for 0/undefined and converts minutes to ms', () => { + expect(timeoutMinutesToMs(undefined)).toBeUndefined() + expect(timeoutMinutesToMs(0)).toBeUndefined() + expect(timeoutMinutesToMs(2)).toBe(2 * 60 * 1000) + }) + + it('normalizeMaxToolSteps uses defaults and clamps', () => { + expect(normalizeMaxToolSteps(undefined, { defaultSteps: 20, maxSteps: 50 })).toBe(20) + expect(normalizeMaxToolSteps(-1, { defaultSteps: 20, maxSteps: 50 })).toBe(20) + expect(normalizeMaxToolSteps(10.2, { defaultSteps: 20, maxSteps: 50 })).toBe(10) + expect(normalizeMaxToolSteps(999, { defaultSteps: 20, maxSteps: 50 })).toBe(50) + }) + + it('buildCombinedAbortSignal returns undefined for empty and combines signals', () => { + expect(buildCombinedAbortSignal([])).toBeUndefined() + + const controllerA = new AbortController() + const controllerB = new AbortController() + + const single = buildCombinedAbortSignal([controllerA.signal]) + expect(single).toBe(controllerA.signal) + + const combined = buildCombinedAbortSignal([controllerA.signal, controllerB.signal]) + expect(combined?.aborted).toBe(false) + controllerB.abort() + expect(combined?.aborted).toBe(true) + }) +}) diff --git a/src/renderer/src/aiCore/utils/streamingTimeout.ts b/src/renderer/src/aiCore/utils/streamingTimeout.ts new file mode 100644 index 000000000..d4f405e8d --- /dev/null +++ b/src/renderer/src/aiCore/utils/streamingTimeout.ts @@ -0,0 +1,40 @@ +export const DEFAULT_MAX_TOOL_STEPS = 20 +export const MAX_MAX_TOOL_STEPS = 500 + +export function normalizeTimeoutMinutes(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + const normalized = Math.max(0, Math.floor(value)) + return normalized +} + +export function timeoutMinutesToMs(minutes: unknown): number | undefined { + const normalized = normalizeTimeoutMinutes(minutes) + if (normalized === undefined || normalized <= 0) return undefined + return normalized * 60 * 1000 +} + +export function normalizeMaxToolSteps( + value: unknown, + options: { + defaultSteps?: number + maxSteps?: number + } = {} +): number { + const defaultSteps = options.defaultSteps ?? DEFAULT_MAX_TOOL_STEPS + const maxSteps = options.maxSteps ?? MAX_MAX_TOOL_STEPS + + if (value === undefined || value === null) return defaultSteps + if (typeof value !== 'number' || !Number.isFinite(value)) return defaultSteps + + const normalized = Math.floor(value) + if (normalized <= 0) return defaultSteps + return Math.min(normalized, maxSteps) +} + +export function buildCombinedAbortSignal(signals: Array): AbortSignal | undefined { + const validSignals = signals.filter((s): s is AbortSignal => Boolean(s)) + if (validSignals.length === 0) return undefined + if (validSignals.length === 1) return validSignals[0] + return AbortSignal.any(validSignals) +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f38cdc1de..c9f38d545 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Remove Invalid Keys", "search": "Search Providers...", "search_placeholder": "Search model id or name", + "streaming": { + "description": "Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "Streaming & Timeouts", + "max_tool_steps": { + "help": "Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "Max tool steps" + }, + "request_timeout": { + "help": "Abort the request after this time. Set to 0 to disable.", + "label": "Request hard timeout (minutes)" + }, + "reset": "Reset to defaults", + "sse_idle_timeout": { + "help": "Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "SSE idle timeout (minutes)" + }, + "title": "Streaming & Timeouts" + }, "title": "Model Provider", "vertex_ai": { "api_host_help": "The API host for Vertex AI, not recommended to fill in, generally applicable to reverse proxy", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 882b897ef..609d0b663 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "删除无效密钥", "search": "搜索模型平台...", "search_placeholder": "搜索模型 ID 或名称", + "streaming": { + "description": "配置长时间流式请求(例如 OpenAI Responses SSE)的客户端限制。", + "label": "流式与超时", + "max_tool_steps": { + "help": "AI SDK 的工具调用会多轮循环。该值用于防止无限循环;若工作流需要更多轮工具调用请调大。", + "label": "最大工具步数" + }, + "request_timeout": { + "help": "超过该时间将由客户端中止请求。设为 0 表示不额外限制。", + "label": "请求硬超时(分钟)" + }, + "reset": "恢复默认", + "sse_idle_timeout": { + "help": "在该时间内未收到任何流式事件则中止请求。长时间无输出/工具执行较慢时可调大;设为 0 关闭。", + "label": "SSE 空闲超时(分钟)" + }, + "title": "流式与超时" + }, "title": "模型服务", "vertex_ai": { "api_host_help": "Vertex AI 的 API 地址,不建议填写,通常适用于反向代理", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3feb287c1..38b6693d1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "刪除無效金鑰", "search": "搜尋模型平臺...", "search_placeholder": "搜尋模型 ID 或名稱", + "streaming": { + "description": "設定長時間串流請求(例如 OpenAI Responses SSE)的用戶端限制。", + "label": "串流與逾時", + "max_tool_steps": { + "help": "AI SDK 的工具呼叫會多輪循環。此值用於避免無限循環;若工作流程需要更多輪工具呼叫請調大。", + "label": "最大工具步數" + }, + "request_timeout": { + "help": "超過此時間用戶端將中止請求。設為 0 表示不額外限制。", + "label": "請求硬逾時(分鐘)" + }, + "reset": "還原預設值", + "sse_idle_timeout": { + "help": "在此時間內未收到任何串流事件則中止請求。長時間無輸出/工具執行較慢時可調大;設為 0 關閉。", + "label": "SSE 閒置逾時(分鐘)" + }, + "title": "串流與逾時" + }, "title": "模型提供者", "vertex_ai": { "api_host_help": "Vertex AI 的 API 位址,不建議填寫,通常適用於反向代理", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index f53597860..d2d0979ff 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Ungültige Schlüssel löschen", "search": "Modellplattform suchen...", "search_placeholder": "Modell-ID oder Name suchen", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "Modelldienst", "vertex_ai": { "api_host_help": "Vertex AI-API-Adresse, nicht empfohlen, normalerweise für Reverse-Proxy geeignet", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 99592e9ad..6b235f018 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Διαγραφή Ακυρωμένων Κλειδιών", "search": "Αναζήτηση πλατφόρμας μονάδων...", "search_placeholder": "Αναζήτηση ID ή ονόματος μονάδας", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "Υπηρεσία μονάδων", "vertex_ai": { "api_host_help": "Η διεύθυνση API του Vertex AI, δεν συνιστάται να συμπληρωθεί, συνήθως κατάλληλη για αντίστροφη διαμεσολάβηση", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 31c715858..9f26d3ac7 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Eliminar claves inválidas", "search": "Buscar plataforma de modelos...", "search_placeholder": "Buscar ID o nombre del modelo", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "Servicio de modelos", "vertex_ai": { "api_host_help": "Dirección de la API de Vertex AI, no se recomienda completar, normalmente aplicable al proxy inverso", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index da1d297a7..0934062e6 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Supprimer les clés invalides", "search": "Rechercher une plateforme de modèles...", "search_placeholder": "Rechercher un ID ou un nom de modèle", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "Services de modèles", "vertex_ai": { "api_host_help": "Adresse API de Vertex AI, il n'est pas recommandé de la remplir, généralement utilisée pour un proxy inverse", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 93bacf506..90f8dd872 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "無効なキーを削除", "search": "プロバイダーを検索...", "search_placeholder": "モデルIDまたは名前を検索", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "モデルプロバイダー", "vertex_ai": { "api_host_help": "Vertex AIのAPIアドレス。逆プロキシに適しています。", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 9bd688167..59c66d3b4 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Remover chaves inválidas", "search": "Procurar plataforma de modelos...", "search_placeholder": "Procurar ID ou nome do modelo", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "Serviços de Modelos", "vertex_ai": { "api_host_help": "O endereço da API do Vertex AI, não é recomendado preencher, normalmente aplicável a proxy reverso", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 7665115d5..fda56b5d0 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4564,6 +4564,24 @@ "remove_invalid_keys": "Удалить недействительные ключи", "search": "Поиск поставщиков...", "search_placeholder": "Поиск по ID или имени модели", + "streaming": { + "description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).", + "label": "[to be translated]:Streaming & Timeouts", + "max_tool_steps": { + "help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.", + "label": "[to be translated]:Max tool steps" + }, + "request_timeout": { + "help": "[to be translated]:Abort the request after this time. Set to 0 to disable.", + "label": "[to be translated]:Request hard timeout (minutes)" + }, + "reset": "[to be translated]:Reset to defaults", + "sse_idle_timeout": { + "help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.", + "label": "[to be translated]:SSE idle timeout (minutes)" + }, + "title": "[to be translated]:Streaming & Timeouts" + }, "title": "Провайдеры моделей", "vertex_ai": { "api_host_help": "API-адрес Vertex AI, не рекомендуется заполнять, обычно применим к обратным прокси", diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 85f54fce8..7d2d4f063 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -36,7 +36,7 @@ import { import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { debounce, isEmpty } from 'lodash' -import { Bolt, Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react' +import { Bolt, Check, Settings2, SquareArrowOutUpRight, Timer, TriangleAlert } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -61,6 +61,7 @@ import LMStudioSettings from './LMStudioSettings' import OVMSSettings from './OVMSSettings' import ProviderOAuth from './ProviderOAuth' import SelectProviderModelPopup from './SelectProviderModelPopup' +import StreamingSettingsPopup from './StreamingSettings/StreamingSettingsPopup' import VertexAISettings from './VertexAISettings' interface Props { @@ -394,6 +395,14 @@ const ProviderSetting: FC = ({ providerId }) => { + + + ) +} + +export default StreamingSettings diff --git a/src/renderer/src/pages/settings/ProviderSettings/StreamingSettings/StreamingSettingsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/StreamingSettings/StreamingSettingsPopup.tsx new file mode 100644 index 000000000..820b8a522 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/StreamingSettings/StreamingSettingsPopup.tsx @@ -0,0 +1,66 @@ +import { TopView } from '@renderer/components/TopView' +import { Modal } from 'antd' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import StreamingSettings from './StreamingSettings' + +interface ShowParams { + providerId: string +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ providerId, resolve }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + StreamingSettingsPopup.hide = onCancel + + return ( + + + + ) +} + +const TopViewKey = 'StreamingSettingsPopup' + +export default class StreamingSettingsPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 3c081c3da..e7929e9f0 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -130,7 +130,8 @@ export async function fetchChatCompletion({ params: aiSdkParams, modelId, capabilities, - webSearchPluginConfig + webSearchPluginConfig, + streamingConfig } = await buildStreamTextParams(messages, assistant, provider, { mcpTools: mcpTools, webSearchProviderId: assistant.webSearchProviderId, @@ -164,7 +165,8 @@ export async function fetchChatCompletion({ assistant, topicId, callType: 'chat', - uiMessages + uiMessages, + streamingConfig }) } diff --git a/src/renderer/src/types/provider.ts b/src/renderer/src/types/provider.ts index 4e3e34760..c62c14abe 100644 --- a/src/renderer/src/types/provider.ts +++ b/src/renderer/src/types/provider.ts @@ -114,6 +114,25 @@ export type Provider = { serviceTier?: ServiceTier verbosity?: OpenAIVerbosity + /** + * Client-side hard timeout for a single model request. + * - `0`/`undefined`: no additional client-enforced timeout (recommended when upstream supports long-running streams). + * - `> 0`: abort the request after N minutes. + */ + requestTimeoutMinutes?: number + /** + * Client-side idle timeout for SSE streaming. + * - `0`/`undefined`: disabled. + * - `> 0`: abort the request if no stream events are received for N minutes. + */ + sseIdleTimeoutMinutes?: number + /** + * Max tool/agent steps for AI SDK multi-step tool calling loop. + * - `undefined`: uses the app default (currently 20). + * - `> 0`: stop after N steps to avoid infinite loops. + */ + maxToolSteps?: number + /** @deprecated */ isNotSupportArrayContent?: boolean /** @deprecated */