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/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index 20aa78dcb..b1d8e34fc 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -79,7 +79,7 @@ vi.mock('@renderer/services/AssistantService', () => ({ import { getProviderByModel } from '@renderer/services/AssistantService' import type { Model, Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' -import { isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provider' +import { isAzureOpenAIProvider, isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provider' import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' import { getActualProvider, providerToAiSdkConfig } from '../providerConfig' @@ -133,6 +133,17 @@ const createPerplexityProvider = (): Provider => ({ isSystem: false }) +const createAzureProvider = (apiVersion: string): Provider => ({ + id: 'azure-openai', + type: 'azure-openai', + name: 'Azure OpenAI', + apiKey: 'test-key', + apiHost: 'https://example.openai.azure.com/openai', + apiVersion, + models: [], + isSystem: true +}) + describe('Copilot responses routing', () => { beforeEach(() => { ;(globalThis as any).window = { @@ -504,3 +515,46 @@ describe('Stream options includeUsage configuration', () => { expect(config.providerId).toBe('github-copilot-openai-compatible') }) }) + +describe('Azure OpenAI traditional API routing', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + mockGetState.mockReturnValue({ + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) + + vi.mocked(isAzureOpenAIProvider).mockImplementation((provider) => provider.type === 'azure-openai') + }) + + it('uses deployment-based URLs when apiVersion is a date version', () => { + const provider = createAzureProvider('2024-02-15-preview') + const config = providerToAiSdkConfig(provider, createModel('gpt-4o', 'GPT-4o', provider.id)) + + expect(config.providerId).toBe('azure') + expect(config.options.apiVersion).toBe('2024-02-15-preview') + expect(config.options.useDeploymentBasedUrls).toBe(true) + }) + + it('does not force deployment-based URLs for apiVersion v1/preview', () => { + const v1Provider = createAzureProvider('v1') + const v1Config = providerToAiSdkConfig(v1Provider, createModel('gpt-4o', 'GPT-4o', v1Provider.id)) + expect(v1Config.providerId).toBe('azure-responses') + expect(v1Config.options.apiVersion).toBe('v1') + expect(v1Config.options.useDeploymentBasedUrls).toBeUndefined() + + const previewProvider = createAzureProvider('preview') + const previewConfig = providerToAiSdkConfig(previewProvider, createModel('gpt-4o', 'GPT-4o', previewProvider.id)) + expect(previewConfig.providerId).toBe('azure-responses') + expect(previewConfig.options.apiVersion).toBe('preview') + expect(previewConfig.options.useDeploymentBasedUrls).toBeUndefined() + }) +}) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 556b870e5..0ad15ea89 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -214,6 +214,15 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A } else if (aiSdkProviderId === 'azure') { extraOptions.mode = 'chat' } + if (isAzureOpenAIProvider(actualProvider)) { + const apiVersion = actualProvider.apiVersion?.trim() + if (apiVersion) { + extraOptions.apiVersion = apiVersion + if (!['preview', 'v1'].includes(apiVersion)) { + extraOptions.useDeploymentBasedUrls = true + } + } + } // bedrock if (aiSdkProviderId === 'bedrock') { 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 f4012363e..b55978217 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4579,6 +4579,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 f0d4adf4c..2e6eb6f98 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4579,6 +4579,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 9625c6838..97ea5a9dc 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4579,6 +4579,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 b3acb4995..9bab42394 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4579,6 +4579,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 ae7b85564..fbbb1b183 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4579,6 +4579,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 26b499cba..863a296e5 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4579,6 +4579,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 4dff56d7e..caf6e5100 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4579,6 +4579,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 090a1927c..6af68ac10 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4579,6 +4579,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 50cc4fae0..fd6c73d45 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4579,6 +4579,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 8a6a78145..c054f1366 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4579,6 +4579,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 049c14c0d..0bfcc051b 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 { @@ -395,6 +396,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 0cd57a353..9d475f17d 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -187,7 +187,8 @@ export async function fetchChatCompletion({ params: aiSdkParams, modelId, capabilities, - webSearchPluginConfig + webSearchPluginConfig, + streamingConfig } = await buildStreamTextParams(messages, assistant, provider, { mcpTools: mcpTools, webSearchProviderId: assistant.webSearchProviderId, @@ -221,7 +222,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 edab3a730..363852fc0 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 */ diff --git a/tests/renderer.setup.ts b/tests/renderer.setup.ts index 9e10e5363..2f47a8d8e 100644 --- a/tests/renderer.setup.ts +++ b/tests/renderer.setup.ts @@ -1,8 +1,15 @@ import '@testing-library/jest-dom/vitest' +import { createRequire } from 'node:module' import { styleSheetSerializer } from 'jest-styled-components/serializer' import { expect, vi } from 'vitest' +const require = createRequire(import.meta.url) +const bufferModule = require('buffer') +if (!bufferModule.SlowBuffer) { + bufferModule.SlowBuffer = bufferModule.Buffer +} + expect.addSnapshotSerializer(styleSheetSerializer) // Mock LoggerService globally for renderer tests @@ -48,3 +55,29 @@ vi.stubGlobal('api', { writeWithId: vi.fn().mockResolvedValue(undefined) } }) + +if (typeof globalThis.localStorage === 'undefined' || typeof (globalThis.localStorage as any).getItem !== 'function') { + let store = new Map() + + const localStorageMock = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)) + }, + removeItem: (key: string) => { + store.delete(key) + }, + clear: () => { + store.clear() + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size + } + } + + vi.stubGlobal('localStorage', localStorageMock) + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + } +}