diff --git a/screenshots/provider-responses-editor.png b/screenshots/provider-responses-editor.png new file mode 100644 index 000000000..4c1cb85b1 Binary files /dev/null and b/screenshots/provider-responses-editor.png differ diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 445148a56..99a160c92 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -125,6 +125,18 @@ "The temperature parameter is not sent. The provider or model default is used.": "The temperature parameter is not sent. The provider or model default is used.", "The current model does not accept a custom temperature. The parameter will not be sent.": "The current model does not accept a custom temperature. The parameter will not be sent.", "API Url": "API Url", + "API Protocol": "API Protocol", + "Chat Completions URL": "Chat Completions URL", + "Responses URL": "Responses URL", + "Default protocol": "Default protocol", + "Use the global OpenAI setting where applicable; otherwise use Chat Completions.": "Use the global OpenAI setting where applicable; otherwise use Chat Completions.", + "Optional when Responses has an explicit URL.": "Optional when Responses has an explicit URL.", + "Leave empty to derive from the Chat Completions URL.": "Leave empty to derive from the Chat Completions URL.", + "Please enter a valid HTTP(S) Responses URL": "Please enter a valid HTTP(S) Responses URL", + "Chat Completions": "Chat Completions", + "Responses": "Responses", + "OpenAI API Protocol": "OpenAI API Protocol", + "Use Responses API (Azure preview)": "Use Responses API (Azure preview)", "Provider": "Provider", "Others": "Others", "API Modes": "API Modes", diff --git a/src/config/index.mjs b/src/config/index.mjs index 6267ef67c..94d6af771 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -19,6 +19,7 @@ import { canonicalizeModelKeyArray, } from './model-key-migrations.mjs' import { getNavigatorLanguage, resolvePreferredLanguageKey } from './language-data.mjs' +import { normalizeExplicitApiProtocol } from '../services/apis/provider-registry.mjs' export { getNavigatorLanguage } @@ -842,6 +843,8 @@ export const defaultConfig = { customChatGptWebApiUrl: 'https://chatgpt.com', customChatGptWebApiPath: '/backend-api/conversation', customOpenAiApiUrl: 'https://api.openai.com', + openaiApiProtocol: 'chat', + azureUseResponses: false, customAnthropicApiUrl: 'https://api.anthropic.com', disableWebModeHistory: true, hideContextMenu: false, @@ -1165,10 +1168,14 @@ function normalizeCustomProviderForStorage(provider, index, providerIdSet) { ) const completionsPath = ensureLeadingSlash(provider.completionsPath, '/v1/completions') const normalizedLegacyProviderIds = legacyProviderIds.length > 0 ? legacyProviderIds : undefined + const apiProtocol = normalizeExplicitApiProtocol(provider.apiProtocol) + const responsesUrl = normalizeText(provider.responsesUrl) const storageShapeChanged = (normalizeText(provider.chatCompletionsPath) || '/v1/chat/completions') !== chatCompletionsPath || (normalizeText(provider.completionsPath) || '/v1/completions') !== completionsPath || + provider.apiProtocol !== (apiProtocol || undefined) || + provider.responsesUrl !== (responsesUrl || undefined) || JSON.stringify(provider.legacyProviderIds) !== JSON.stringify(normalizedLegacyProviderIds) return { originalId, @@ -1186,6 +1193,8 @@ function normalizeCustomProviderForStorage(provider, index, providerIdSet) { completionsUrl: normalizeText(provider.completionsUrl), enabled: provider.enabled !== false, allowLegacyResponseField: provider.allowLegacyResponseField !== false, + ...(apiProtocol ? { apiProtocol } : {}), + ...(responsesUrl ? { responsesUrl } : {}), ...(sourceProviderId ? { sourceProviderId } : {}), ...(normalizedLegacyProviderIds ? { legacyProviderIds: normalizedLegacyProviderIds } : {}), }, diff --git a/src/popup/sections/AdvancedPart.jsx b/src/popup/sections/AdvancedPart.jsx index 017b9191f..38351bcb5 100644 --- a/src/popup/sections/AdvancedPart.jsx +++ b/src/popup/sections/AdvancedPart.jsx @@ -136,6 +136,20 @@ function ApiUrl({ config, updateConfig }) { }} /> + + {t('OpenAI API Protocol')} + { + updateConfig({ + openaiApiProtocol: e.target.value === 'responses' ? 'responses' : 'chat', + }) + }} + > + {t('Chat Completions')} + {t('Responses')} + + {t('Custom Anthropic API Url')} { @@ -261,10 +261,7 @@ export function ApiModes({ config, updateConfig }) { event.preventDefault() if (!selectedCustomProvider) return setProviderEditingId(selectedCustomProvider.id) - setProviderDraft({ - name: selectedCustomProvider.name || '', - apiUrl: resolveProviderChatEndpointUrl(selectedCustomProvider), - }) + setProviderDraft(buildProviderDraft(selectedCustomProvider)) setProviderDraftValidation(defaultProviderDraftValidation) setIsProviderEditorOpen(true) } @@ -276,18 +273,25 @@ export function ApiModes({ config, updateConfig }) { pendingNewProvider && pendingNewProvider.id === providerEditingId ? pendingNewProvider : selectedCustomProvider || {} - const endpointDraft = validateProviderEndpointDraft(providerDraft.apiUrl) + const endpointDraft = validateProviderEndpointDraft(providerDraft.apiUrl, providerDraft) + const responsesEndpointDraft = validateProviderResponsesEndpointDraft( + providerDraft, + providerEditingId ? existingProvider : undefined, + ) const parsedEndpoint = endpointDraft.parsedEndpoint const nextProviderDraftValidation = { name: !providerName, apiUrl: !endpointDraft.valid, + responsesUrl: !responsesEndpointDraft.valid, } - if (nextProviderDraftValidation.name || nextProviderDraftValidation.apiUrl) { + if (Object.values(nextProviderDraftValidation).some(Boolean)) { setProviderDraftValidation(nextProviderDraftValidation) if (nextProviderDraftValidation.name) { providerNameInputRef.current?.focus() - } else { + } else if (nextProviderDraftValidation.apiUrl) { providerBaseUrlInputRef.current?.focus() + } else { + providerResponsesUrlInputRef.current?.focus() } return } @@ -299,6 +303,7 @@ export function ApiModes({ config, updateConfig }) { providerName, parsedEndpoint, providerDraft.apiUrl, + providerDraft, ) : null @@ -319,17 +324,20 @@ export function ApiModes({ config, updateConfig }) { ...Object.values(OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID), ...pendingDeletedProviderIds, ]) - const createdProvider = { - id: providerId, - name: providerName, - baseUrl: '', - chatCompletionsPath: '/v1/chat/completions', - completionsPath: '/v1/completions', - chatCompletionsUrl: parsedEndpoint.chatCompletionsUrl, - completionsUrl: parsedEndpoint.completionsUrl, - enabled: true, - allowLegacyResponseField: true, - } + const createdProvider = buildEditedProvider( + { + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + enabled: true, + allowLegacyResponseField: true, + }, + providerId, + providerName, + parsedEndpoint, + providerDraft.apiUrl, + providerDraft, + ) setPendingNewProvider(createdProvider) setProviderSelector(providerId) setProviderSelectionValidation(false) @@ -550,27 +558,71 @@ export function ApiModes({ config, updateConfig }) { aria-invalid={providerDraftValidation.name} style={providerDraftValidation.name ? { borderColor: 'red' } : undefined} /> - { - setProviderDraft({ ...providerDraft, apiUrl: e.target.value }) - if (providerDraftValidation.apiUrl) { - setProviderDraftValidation({ - ...providerDraftValidation, - apiUrl: false, - }) - } - }} - aria-invalid={providerDraftValidation.apiUrl} - style={providerDraftValidation.apiUrl ? { borderColor: 'red' } : undefined} - /> + + {t('Chat Completions URL')} + { + setProviderDraft({ ...providerDraft, apiUrl: e.target.value }) + if (providerDraftValidation.apiUrl) { + setProviderDraftValidation({ + ...providerDraftValidation, + apiUrl: false, + }) + } + }} + aria-invalid={providerDraftValidation.apiUrl} + style={providerDraftValidation.apiUrl ? { borderColor: 'red' } : undefined} + /> + + {t('Optional when Responses has an explicit URL.')} {providerDraftValidation.apiUrl && ( {t('Please enter a full Chat Completions URL')} )} + + {t('Responses URL')} + { + setProviderDraft({ ...providerDraft, responsesUrl: e.target.value }) + if (providerDraftValidation.responsesUrl) { + setProviderDraftValidation({ ...providerDraftValidation, responsesUrl: false }) + } + }} + aria-invalid={providerDraftValidation.responsesUrl} + style={providerDraftValidation.responsesUrl ? { borderColor: 'red' } : undefined} + /> + + {t('Leave empty to derive from the Chat Completions URL.')} + {providerDraftValidation.responsesUrl && ( + {t('Please enter a valid HTTP(S) Responses URL')} + )} + + {t('API Protocol')} + { + setProviderDraft({ ...providerDraft, apiProtocol: e.target.value }) + }} + > + {t('Default protocol')} + {t('Chat Completions')} + {t('Responses')} + + + {providerDraft.apiProtocol === 'default' && ( + + {t('Use the global OpenAI setting where applicable; otherwise use Chat Completions.')} + + )} )} + {isUsingAzureOpenAiApiModel(config) && ( + + { + updateConfig({ azureUseResponses: e.target.checked }) + }} + /> + {t('Use Responses API (Azure preview)')} + + )} {isUsingGithubThirdPartyApiModel(config) && ( + generateAzureOpenaiRequest(port, question, session, abortContext), + ) +} + +async function generateAzureOpenaiRequest(port, question, session, abortContext) { const config = await getUserConfig() + if (abortContext.controller.signal.aborted) return + if (config.azureUseResponses === true) { + let deploymentName = getModelValue(session) + if (!deploymentName) deploymentName = config.azureDeploymentName + const requestUrl = `${config.azureEndpoint.replace( + /\/$/, + '', + )}/openai/responses?api-version=${AZURE_RESPONSES_API_VERSION}` + try { + await generateAnswersWithOpenAIResponses({ + abortContext, + port, + question, + session, + requestUrl, + model: deploymentName, + // Deployment names are opaque aliases, not canonical model identifiers. + temperatureModel: null, + apiKey: '', + config, + provider: 'azure', + extraHeaders: { 'api-key': config.azureApiKey }, + }) + return + } catch (error) { + if (abortContext.controller.signal.aborted) return + if (!shouldFallbackToChatCompletions(error)) throw error + console.warn( + '[azure-openai] Responses API unsupported, falling back to Chat Completions', + error, + ) + } + } + return generateAnswersWithAzureChatCompletions(port, question, session, config, abortContext) +} + +async function generateAnswersWithAzureChatCompletions( + port, + question, + session, + config, + abortContext, +) { + const { controller } = abortContext + if (controller.signal.aborted) return let deploymentName = getModelValue(session) if (!deploymentName) deploymentName = config.azureDeploymentName @@ -44,6 +109,7 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session) ...getTemperatureParams(config), }), onMessage(message) { + if (controller.signal.aborted) return console.debug('sse message', message) let data try { @@ -71,18 +137,11 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session) }, async onStart() {}, async onEnd(aborted) { - try { - if (!aborted) { - port.postMessage({ done: true }) - } - } finally { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) + if (!aborted && !controller.signal.aborted) { + port.postMessage({ done: true }) } }, async onError(resp) { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) if (resp instanceof Error) throw resp const error = await resp.json().catch(() => ({})) throw new Error( diff --git a/src/services/apis/openai-api.mjs b/src/services/apis/openai-api.mjs index 86ae062d8..a9ed08db0 100644 --- a/src/services/apis/openai-api.mjs +++ b/src/services/apis/openai-api.mjs @@ -1,8 +1,16 @@ import { getUserConfig } from '../../config/index.mjs' import { getModelValue } from '../../utils/model-name-convert.mjs' import { generateAnswersWithOpenAICompatible } from './openai-compatible-core.mjs' +import { withAbortController } from './shared.mjs' import { + generateAnswersWithOpenAIResponses, + isResponsesRouteUnsupportedError, +} from './openai-responses-core.mjs' +import { + API_PROTOCOL_RESPONSES, + deriveResponsesUrlFromChatUrl, getOpenAICompatibleRequestDiagnostic, + normalizeExplicitApiProtocol, resolveOpenAICompatibleRequest, } from './provider-registry.mjs' @@ -76,6 +84,46 @@ function buildOpenAICompatibleResolutionErrorMessage(diagnostic) { ) } +function hasOpenAILineage(request) { + return ( + request?.providerId === 'openai' || + request?.secretProviderId === 'openai' || + request?.provider?.sourceProviderId === 'openai' + ) +} + +function shouldUseResponsesProtocol(request, config, session) { + // The legacy prompt-based completions endpoint has no Responses equivalent. + if (request?.endpointType === 'completion') return false + const explicitProtocol = + normalizeExplicitApiProtocol(session?.apiMode?.apiProtocol) || + normalizeExplicitApiProtocol(request?.provider?.apiProtocol) + if (explicitProtocol) return explicitProtocol === API_PROTOCOL_RESPONSES + if ( + String(config?.openaiApiProtocol || '') + .trim() + .toLowerCase() !== API_PROTOCOL_RESPONSES + ) { + return false + } + return hasOpenAILineage(request) +} + +function resolveResponsesRequestUrl(request) { + if (request?.apiProtocol === API_PROTOCOL_RESPONSES) return request.requestUrl + return ( + String(request?.provider?.responsesUrl || '').trim() || + deriveResponsesUrlFromChatUrl(request?.requestUrl) + ) +} + +function shouldFallbackToChatCompletions(error) { + // Only fall back on initial HTTP failures. Mid-stream errors (no status) + // may already have emitted partial answers; retrying via Chat would duplicate them. + if (error?.status == null) return false + return isResponsesRouteUnsupportedError(error) +} + function hasNativeOpenAIRequestUrl(requestUrl) { const normalizedRequestUrl = normalizeBaseUrl(requestUrl) if (!normalizedRequestUrl) return false @@ -156,6 +204,14 @@ function hasNativeOllamaChatApiPath(requestUrl) { } } +function assertSupportedChatEndpoint(requestUrl) { + if (hasNativeOllamaChatApiPath(requestUrl)) { + throw new Error( + 'Unsupported native Ollama chat endpoint. Use the OpenAI-compatible /v1/chat/completions endpoint instead.', + ) + } +} + function hasOllamaMessagesPath(requestUrl) { const normalizedRequestUrl = normalizeBaseUrl(requestUrl) if (!normalizedRequestUrl) return false @@ -304,37 +360,90 @@ export async function generateAnswersWithOpenAiApiCompat( * @param {UserConfig} config */ export async function generateAnswersWithOpenAICompatibleApi(port, question, session, config) { + return withAbortController(port, (abortContext) => + generateOpenAICompatibleRequest(port, question, session, config, abortContext), + ) +} + +async function generateOpenAICompatibleRequest(port, question, session, config, abortContext) { const runtimeConfig = await resolveOpenAICompatibleRuntimeConfig(config) + if (abortContext.controller.signal.aborted) return const request = resolveOpenAICompatibleRequest(runtimeConfig, session) if (!request) { const diagnostic = getOpenAICompatibleRequestDiagnostic(runtimeConfig, session) console.warn('[openai-compatible] Failed to resolve provider request', diagnostic) throw new Error(buildOpenAICompatibleResolutionErrorMessage(diagnostic)) } - if (hasNativeOllamaChatApiPath(request.requestUrl)) { - throw new Error( - 'Unsupported native Ollama chat endpoint. Use the OpenAI-compatible /v1/chat/completions endpoint instead.', - ) - } - const model = resolveModelName(session, runtimeConfig) const providerRequestShapingId = resolveProviderRequestShapingId(request) - await generateAnswersWithOpenAICompatible({ - port, - question, - session, - endpointType: request.endpointType, - requestUrl: request.requestUrl, - model, - apiKey: request.apiKey, - config: runtimeConfig, - provider: providerRequestShapingId, - extraHeaders: getOpenRouterAttributionHeaders(request.requestUrl), - allowLegacyResponseField: request.provider.allowLegacyResponseField, - }) + let completedRequest = request + if (shouldUseResponsesProtocol(request, runtimeConfig, session)) { + const responsesRequestUrl = resolveResponsesRequestUrl(request) + try { + await generateAnswersWithOpenAIResponses({ + abortContext, + port, + question, + session, + requestUrl: responsesRequestUrl, + model, + apiKey: request.apiKey, + config: runtimeConfig, + provider: providerRequestShapingId, + extraHeaders: getOpenRouterAttributionHeaders(responsesRequestUrl), + }) + } catch (error) { + if (abortContext.controller.signal.aborted) return + if (!shouldFallbackToChatCompletions(error)) throw error + if (!request.chatCompletionsUrl) throw error + const fallbackChatUrl = request.chatCompletionsUrl + try { + if (!['http:', 'https:'].includes(new URL(fallbackChatUrl).protocol)) throw error + } catch { + throw error + } + console.warn( + '[openai-compatible] Responses API unsupported, falling back to Chat Completions', + { requestUrl: responsesRequestUrl, error }, + ) + const fallbackRequest = { ...request, requestUrl: fallbackChatUrl } + assertSupportedChatEndpoint(fallbackRequest.requestUrl) + await generateAnswersWithOpenAICompatible({ + abortContext, + port, + question, + session, + endpointType: request.endpointType, + requestUrl: fallbackRequest.requestUrl, + model, + apiKey: request.apiKey, + config: runtimeConfig, + provider: resolveProviderRequestShapingId(fallbackRequest), + extraHeaders: getOpenRouterAttributionHeaders(fallbackRequest.requestUrl), + allowLegacyResponseField: request.provider.allowLegacyResponseField, + }) + completedRequest = fallbackRequest + } + } else { + assertSupportedChatEndpoint(request.requestUrl) + await generateAnswersWithOpenAICompatible({ + abortContext, + port, + question, + session, + endpointType: request.endpointType, + requestUrl: request.requestUrl, + model, + apiKey: request.apiKey, + config: runtimeConfig, + provider: providerRequestShapingId, + extraHeaders: getOpenRouterAttributionHeaders(request.requestUrl), + allowLegacyResponseField: request.provider.allowLegacyResponseField, + }) + } - if (shouldSendOllamaKeepAlive(request)) { - const ollamaKeepAliveBaseUrl = resolveOllamaKeepAliveBaseUrl(request) + if (!abortContext.controller.signal.aborted && shouldSendOllamaKeepAlive(completedRequest)) { + const ollamaKeepAliveBaseUrl = resolveOllamaKeepAliveBaseUrl(completedRequest) await touchOllamaKeepAlive( ollamaKeepAliveBaseUrl, runtimeConfig.ollamaKeepAliveTime, diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index 02dfe4a81..42dff1b48 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -1,7 +1,7 @@ import { fetchSSE } from '../../utils/fetch-sse.mjs' import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' import { isEmpty } from 'lodash-es' -import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs' +import { getCompletionPromptBase, pushRecord, withAbortController } from './shared.mjs' import { getChatCompletionsTokenParams } from './openai-token-params.mjs' import { getTemperatureParams } from './temperature-params.mjs' @@ -46,8 +46,17 @@ function hasFinished(data) { * @param {Record} [params.extraBody] * @param {Record} [params.extraHeaders] * @param {boolean} [params.allowLegacyResponseField] + * @param {ReturnType} [params.abortContext] */ -export async function generateAnswersWithOpenAICompatible({ +export async function generateAnswersWithOpenAICompatible(params) { + return withAbortController( + params.port, + (abortContext) => generateCompatibleRequest({ ...params, abortContext }), + params.abortContext, + ) +} + +async function generateCompatibleRequest({ port, question, session, @@ -60,14 +69,10 @@ export async function generateAnswersWithOpenAICompatible({ extraBody = {}, extraHeaders = {}, allowLegacyResponseField = false, + abortContext, }) { - const { - controller, - messageListener, - disconnectListener, - getStopGenerationId, - isCurrentSessionRequest, - } = setAbortController(port) + const { controller, getStopGenerationId, isCurrentSessionRequest } = abortContext + if (controller.signal.aborted) return let requestBody const conversationRecords = Array.isArray(session.conversationRecords) @@ -114,6 +119,7 @@ export async function generateAnswersWithOpenAICompatible({ } } + if (controller.signal.aborted) return let answer = '' let finished = false const finish = () => { @@ -129,7 +135,7 @@ export async function generateAnswersWithOpenAICompatible({ headers: buildHeaders(apiKey, extraHeaders), body: JSON.stringify(requestBody), onMessage(message) { - if (finished) return + if (finished || controller.signal.aborted) return if (message.trim() === '[DONE]') { finish() return @@ -151,37 +157,30 @@ export async function generateAnswersWithOpenAICompatible({ }, async onStart() {}, async onEnd(aborted = false) { - try { - if (!finished) { - if (aborted) { - const shouldPostSession = Boolean(answer) || session.isRetry - if (shouldPostSession && isCurrentSessionRequest()) { - if (answer) { - pushRecord(session, question, answer) - } - session.isRetry = false - try { - const stoppedGenerationId = getStopGenerationId() - port.postMessage({ - session, - ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }), - }) - } catch (e) { - console.warn('[openai-compatible-core] Failed to post session on abort:', e) - } + if (!finished) { + if (aborted || controller.signal.aborted) { + const shouldPostSession = Boolean(answer) || session.isRetry + if (shouldPostSession && isCurrentSessionRequest()) { + if (answer) { + pushRecord(session, question, answer) + } + session.isRetry = false + try { + const stoppedGenerationId = getStopGenerationId() + port.postMessage({ + session, + ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }), + }) + } catch (e) { + console.warn('[openai-compatible-core] Failed to post session on abort:', e) } - } else { - finish() } + } else { + finish() } - } finally { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) } }, async onError(resp) { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) if (resp instanceof Error) throw resp const error = await resp.json().catch(() => ({})) throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) diff --git a/src/services/apis/openai-responses-core.mjs b/src/services/apis/openai-responses-core.mjs new file mode 100644 index 000000000..4d8491517 --- /dev/null +++ b/src/services/apis/openai-responses-core.mjs @@ -0,0 +1,354 @@ +import { fetchSSE } from '../../utils/fetch-sse.mjs' +import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' +import { isEmpty } from 'lodash-es' +import { pushRecord, withAbortController } from './shared.mjs' +import { getTemperatureParams } from './temperature-params.mjs' + +function buildHeaders(apiKey, extraHeaders = {}) { + const headers = { + 'Content-Type': 'application/json', + ...extraHeaders, + } + if (apiKey) headers.Authorization = `Bearer ${apiKey}` + return headers +} + +export function buildResponsesInput(conversationRecords, question, maxContextLength) { + const records = Array.isArray(conversationRecords) ? conversationRecords : [] + const hasContextLimit = Number.isFinite(maxContextLength) && maxContextLength >= 0 + const limitedRecords = hasContextLimit + ? records.slice(Math.max(records.length - maxContextLength, 0)) + : records + const input = getConversationPairs(limitedRecords, false) + input.push({ role: 'user', content: question }) + return input +} + +function convertResponseFormatToTextFormat(responseFormat) { + if (!responseFormat || typeof responseFormat !== 'object') return undefined + if (responseFormat.type === 'json_schema') { + const description = responseFormat.description ?? responseFormat.json_schema?.description + return { + format: { + type: 'json_schema', + name: responseFormat.name || responseFormat.json_schema?.name || 'response', + strict: (responseFormat.strict ?? responseFormat.json_schema?.strict) !== false, + schema: responseFormat.schema || responseFormat.json_schema?.schema || {}, + ...(description !== undefined ? { description } : {}), + }, + } + } + if (responseFormat.type === 'json_object') { + return { format: { type: 'json_object' } } + } + return undefined +} + +export function buildResponsesBody({ + model, + temperatureModel = model, + input, + instructions, + config, + extraBody = {}, +}) { + const safeExtraBody = { ...extraBody } + delete safeExtraBody.temperature + delete safeExtraBody.max_tokens + delete safeExtraBody.max_completion_tokens + delete safeExtraBody.messages + delete safeExtraBody.prompt + delete safeExtraBody.stream + + const { response_format: responseFormat, ...restExtraBody } = safeExtraBody + + const body = { + model, + input, + stream: true, + store: false, + max_output_tokens: config?.maxResponseTokenLength, + ...getTemperatureParams(config, temperatureModel), + ...restExtraBody, + } + + if (instructions) body.instructions = instructions + + if (responseFormat && !body.text) { + const converted = convertResponseFormatToTextFormat(responseFormat) + if (converted) body.text = converted + } + + return body +} + +function extractTextFromOutputItemContent(content) { + if (!Array.isArray(content)) return '' + let text = '' + for (const part of content) { + if (part && typeof part === 'object' && part.type === 'output_text') { + if (typeof part.text === 'string') text += part.text + } else if (part && typeof part === 'object' && typeof part.refusal === 'string') { + text += part.refusal + } + } + return text +} + +export function extractResponsesOutputText(data) { + if (!data || typeof data !== 'object') return '' + if (typeof data.output_text === 'string' && data.output_text) return data.output_text + if (!Array.isArray(data.output)) return '' + let text = '' + for (const item of data.output) { + if (!item || typeof item !== 'object') continue + if (item.type === 'message') { + text += extractTextFromOutputItemContent(item.content) + } + } + return text +} + +function getResponsesErrorMessage(data) { + const message = + data?.response?.error?.message || + data?.error?.message || + data?.message || + (typeof data?.error === 'string' ? data.error : '') + return message || 'Responses API request failed' +} + +/** + * Whether an error from a Responses API request looks like "route not supported" + * (unknown endpoint, unsupported model/API version) as opposed to a request, + * auth, or mid-stream failure. A 404 retains the legacy route fallback. Other + * eligible HTTP statuses require an explicit Responses incompatibility message. + */ +export function isResponsesRouteUnsupportedError(error) { + if (error?.status === 404) return true + if (![400, 405, 501].includes(error?.status)) return false + let message = String(error?.message || '') + let code + try { + // HTTP errors are serialized JSON. Do not classify paths or hints from metadata. + const body = JSON.parse(message) + code = body?.error?.code || body?.code + message = + typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message || '' + } catch { + // Some compatible servers return plain-text errors. + } + if (typeof message !== 'string') return false + const namesResponsesRoute = + /\/(?:v1|openai)\/responses\b|\bresponses\s+api\b|\bresponses\b.*api-version|api-version.*\bresponses\b/i.test( + message, + ) + if (!namesResponsesRoute) return false + if (code === 'model_not_found') return true + // Require the failure to describe the endpoint, model, or API version itself, + // not arbitrary input, parameters, files, or credentials mentioned elsewhere. + const subject = '(?:api[- ]version|url|endpoint|route|model|responses\\s+api)' + const value = '(?:\\s+(?:"[^"\\n]+"|\'[^\'\\n]+\'|\\/(?:v1|openai)\\/responses|\\d[\\w.-]*))?' + const failure = '(?:not found|does not exist|not supported|unsupported|invalid)' + return ( + /\b(?:unknown|unsupported|invalid)\s+(?:api[- ]version|url|endpoint|route)\b/i.test(message) || + new RegExp(`\\b${subject}${value}\\s+(?:(?:is|was)\\s+)?${failure}\\b`, 'i').test(message) || + /\bmodel(?:\s+(?:"[^"\n]+"|'[^'\n]+'|[\w.-]+))?\s+does not support\s+(?:the\s+)?(?:responses\s+api|\/(?:v1|openai)\/responses)\b/i.test( + message, + ) || + /\/(?:v1|openai)\/responses\b["']?\s+(?:is\s+)?(?:not found|does not exist|not supported|unsupported)\b/i.test( + message, + ) + ) +} + +/** + * Apply one parsed Responses SSE event payload to the accumulated answer. + * @returns {{ answer: string, done: boolean, failed: boolean, error?: Error }} + */ +export function applyResponsesStreamEvent(answer, data) { + if (!data || typeof data !== 'object') return { answer, done: false, failed: false } + const eventType = data.type + if ( + !isEmpty(data.error) || + eventType === 'response.failed' || + eventType === 'error' || + data.status === 'failed' + ) { + return { answer, done: false, failed: true, error: new Error(getResponsesErrorMessage(data)) } + } + // A standalone response snapshot must not become a success via the synthetic DONE marker. + // Streaming lifecycle events can legitimately carry an in-progress response. + if (!eventType && ['queued', 'in_progress', 'cancelled'].includes(data.status)) { + return { + answer, + done: false, + failed: true, + error: new Error(`Responses API returned a non-success status: ${data.status}`), + } + } + if (eventType === 'response.output_text.delta' && typeof data.delta === 'string') { + return { answer: answer + data.delta, done: false, failed: false } + } + if (eventType === 'response.incomplete' || (!eventType && data.status === 'incomplete')) { + const response = data.response || data + const fullText = extractResponsesOutputText(response) + if (!fullText && !answer) { + const reason = response.incomplete_details?.reason || 'no output text' + return { + answer, + done: false, + failed: true, + error: new Error(`Responses API response incomplete: ${reason}`), + } + } + return { answer: fullText || answer, done: true, failed: false } + } + if (eventType === 'response.completed') { + const fullText = extractResponsesOutputText(data.response || data) + return { answer: fullText || answer, done: true, failed: false } + } + if (Array.isArray(data.output) || typeof data.output_text === 'string') { + const fullText = extractResponsesOutputText(data) + if (fullText) return { answer: fullText, done: false, failed: false } + } + return { answer, done: false, failed: false } +} + +/** + * @param {object} params + * @param {Browser.Runtime.Port} params.port + * @param {string} params.question + * @param {Session} params.session + * @param {string} params.requestUrl + * @param {string} params.model + * @param {string | null} [params.temperatureModel] Canonical model ID, or null for opaque aliases. + * @param {string} params.apiKey + * @param {UserConfig} params.config + * @param {Record} [params.extraBody] + * @param {Record} [params.extraHeaders] + * @param {ReturnType} [params.abortContext] + */ +export async function generateAnswersWithOpenAIResponses(params) { + return withAbortController( + params.port, + (abortContext) => generateResponsesRequest({ ...params, abortContext }), + params.abortContext, + ) +} + +async function generateResponsesRequest({ + port, + question, + session, + requestUrl, + model, + temperatureModel = model, + apiKey, + config, + extraBody = {}, + extraHeaders = {}, + abortContext, +}) { + const { controller, getStopGenerationId, isCurrentSessionRequest } = abortContext + if (controller.signal.aborted) return + + const conversationRecords = Array.isArray(session.conversationRecords) + ? session.conversationRecords + : [] + session.conversationRecords = conversationRecords + const requestBody = buildResponsesBody({ + model, + temperatureModel, + input: buildResponsesInput(conversationRecords, question, config?.maxConversationContextLength), + config, + extraBody, + }) + + let answer = '' + let finished = false + const finish = () => { + if (finished) return + finished = true + if (!answer) throw new Error('Responses API completed without output text') + pushRecord(session, question, answer) + port.postMessage({ answer: null, done: true, session: session }) + } + const fail = (error) => { + if (finished) return + finished = true + throw error + } + + await fetchSSE(requestUrl, { + bufferJsonResponse: true, + method: 'POST', + signal: controller.signal, + headers: buildHeaders(apiKey, extraHeaders), + body: JSON.stringify(requestBody), + onMessage(message) { + if (finished || controller.signal.aborted) return + if (message.trim() === '[DONE]') { + finish() + return + } + let data + try { + data = JSON.parse(message) + } catch (error) { + fail(error) + return + } + + const result = applyResponsesStreamEvent(answer, data) + if (result.failed) { + fail(result.error) + return + } + answer = result.answer + port.postMessage({ answer: answer, done: false, session: null }) + + if (result.done) { + finish() + } + }, + async onStart() {}, + async onEnd(aborted = false) { + if (!finished) { + if (aborted || controller.signal.aborted) { + const shouldPostSession = Boolean(answer) || session.isRetry + if (shouldPostSession && isCurrentSessionRequest()) { + if (answer) { + pushRecord(session, question, answer) + } + session.isRetry = false + try { + const stoppedGenerationId = getStopGenerationId() + port.postMessage({ + session, + ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }), + }) + } catch (e) { + console.warn('[openai-responses-core] Failed to post session on abort:', e) + } + } + } else { + fail(new Error('Responses API stream ended before completion')) + } + } + }, + async onError(resp) { + if (resp instanceof Error) throw resp + let message = await resp.text().catch(() => '') + try { + const errorBody = JSON.parse(message) + message = isEmpty(errorBody) ? '' : JSON.stringify(errorBody) + } catch { + // Keep plain-text error details; the body has already been consumed once. + } + const error = new Error(message.trim() || `${resp.status} ${resp.statusText}`) + error.status = resp?.status + throw error + }, + }) +} diff --git a/src/services/apis/provider-registry.mjs b/src/services/apis/provider-registry.mjs index 206cc4481..fbe0782cd 100644 --- a/src/services/apis/provider-registry.mjs +++ b/src/services/apis/provider-registry.mjs @@ -8,6 +8,66 @@ export { OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID } const DEFAULT_CHAT_PATH = '/v1/chat/completions' const DEFAULT_COMPLETION_PATH = '/v1/completions' +export const API_PROTOCOL_CHAT = 'chat' +export const API_PROTOCOL_RESPONSES = 'responses' + +export function normalizeExplicitApiProtocol(value) { + const protocol = toStringOrEmpty(value).trim().toLowerCase() + return protocol === API_PROTOCOL_CHAT || protocol === API_PROTOCOL_RESPONSES + ? protocol + : undefined +} + +export function normalizeApiProtocol(value) { + return normalizeExplicitApiProtocol(value) || API_PROTOCOL_CHAT +} + +export function isResponsesProtocol(value) { + if (value && typeof value === 'object') { + return normalizeApiProtocol(value.apiProtocol) === API_PROTOCOL_RESPONSES + } + return normalizeApiProtocol(value) === API_PROTOCOL_RESPONSES +} + +export function resolveApiProtocolForSession(session, provider) { + return ( + normalizeExplicitApiProtocol(session?.apiMode?.apiProtocol) || + normalizeExplicitApiProtocol(provider?.apiProtocol) || + API_PROTOCOL_CHAT + ) +} + +export function deriveResponsesUrlFromChatUrl(chatUrl) { + const normalizedChatUrl = toStringOrEmpty(chatUrl).trim() + if (!normalizedChatUrl) return '' + try { + const url = new URL(normalizedChatUrl) + const pathname = url.pathname.replace(/\/+$/, '') + url.pathname = /\/responses$/i.test(pathname) + ? pathname + : /\/chat\/completions$/i.test(pathname) + ? pathname.replace(/\/chat\/completions$/i, '/responses') + : `${pathname}/responses` + return url.href + } catch { + return '' + } +} + +export function deriveChatCompletionsUrlFromResponsesUrl(responsesUrl) { + const normalizedResponsesUrl = toStringOrEmpty(responsesUrl).trim() + if (!normalizedResponsesUrl) return '' + try { + const url = new URL(normalizedResponsesUrl) + const pathname = url.pathname.replace(/\/+$/, '') + if (!/\/responses$/i.test(pathname)) return normalizedResponsesUrl + url.pathname = pathname.replace(/\/responses$/i, '/chat/completions') + return url.href + } catch { + return '' + } +} + const BUILTIN_PROVIDER_TEMPLATE = [ { id: 'openai', @@ -352,6 +412,8 @@ function normalizeCustomProvider(provider, index) { const completionsPath = ensureLeadingSlash(provider.completionsPath, DEFAULT_COMPLETION_PATH) const chatCompletionsUrl = toStringOrEmpty(provider.chatCompletionsUrl).trim() const completionsUrl = toStringOrEmpty(provider.completionsUrl).trim() + const responsesUrl = toStringOrEmpty(provider.responsesUrl).trim() + const apiProtocol = normalizeExplicitApiProtocol(provider.apiProtocol) let baseUrl = trimSlashes(provider.baseUrl) if (!chatCompletionsUrl && !completionsUrl) { @@ -372,6 +434,8 @@ function normalizeCustomProvider(provider, index) { builtin: false, enabled: provider.enabled !== false, allowLegacyResponseField: provider.allowLegacyResponseField !== false, + ...(apiProtocol ? { apiProtocol } : {}), + ...(responsesUrl ? { responsesUrl } : {}), ...(sourceProviderId ? { sourceProviderId } : {}), ...(legacyProviderIds.length > 0 ? { legacyProviderIds } : {}), } @@ -684,6 +748,28 @@ export function getOpenAICompatibleRequestDiagnostic(config, session) { } } +function resolveResponsesUrlFromProvider(provider, session, useLegacyCustomUrlFallback) { + const explicitResponsesUrl = toStringOrEmpty(provider?.responsesUrl).trim() + if (explicitResponsesUrl) return explicitResponsesUrl + + const legacyCustomUrl = + session?.apiMode && + typeof session.apiMode === 'object' && + session.apiMode.groupName === 'customApiModelKeys' && + useLegacyCustomUrlFallback + ? toStringOrEmpty(session.apiMode.customUrl).trim() + : '' + if (legacyCustomUrl) return deriveResponsesUrlFromChatUrl(legacyCustomUrl) + + if (provider?.chatCompletionsUrl) { + return deriveResponsesUrlFromChatUrl(provider.chatCompletionsUrl) + } + if (provider?.baseUrl && provider?.chatCompletionsPath) { + return deriveResponsesUrlFromChatUrl(joinUrl(provider.baseUrl, provider.chatCompletionsPath)) + } + return '' +} + function resolveUrlFromProvider( provider, endpointType, @@ -693,6 +779,48 @@ function resolveUrlFromProvider( ) { if (!provider) return '' + // The legacy prompt-based completions endpoint has no Responses equivalent. + if ( + endpointType !== 'completion' && + resolveApiProtocolForSession(session, provider) === API_PROTOCOL_RESPONSES + ) { + if (provider.id === 'legacy-custom-default') { + if (toStringOrEmpty(session?.apiMode?.customUrl).trim() && useLegacyCustomUrlFallback) { + return resolveResponsesUrlFromProvider(provider, session, useLegacyCustomUrlFallback) + } + return ( + toStringOrEmpty(provider.responsesUrl).trim() || + deriveResponsesUrlFromChatUrl( + toStringOrEmpty(config.customModelApiUrl).trim() || + 'http://localhost:8000/v1/chat/completions', + ) + ) + } + const responsesUrl = resolveResponsesUrlFromProvider( + provider, + session, + useLegacyCustomUrlFallback, + ) + if (responsesUrl) return responsesUrl + return '' + } + + return resolveChatOrCompletionsUrlFromProvider( + provider, + endpointType, + config, + session, + useLegacyCustomUrlFallback, + ) +} + +function resolveChatOrCompletionsUrlFromProvider( + provider, + endpointType, + config, + session, + useLegacyCustomUrlFallback, +) { const apiModeCustomUrl = endpointType === 'chat' && session?.apiMode && @@ -913,6 +1041,7 @@ export function resolveOpenAICompatibleRequest(config, session) { } if (!provider) return null const endpointType = resolveEndpointTypeForSession(session) + const apiProtocol = resolveApiProtocolForSession(session, provider) const requestUrl = resolveUrlFromProvider( provider, endpointType, @@ -926,7 +1055,18 @@ export function resolveOpenAICompatibleRequest(config, session) { secretProviderId: resolveSecretProviderId(config, recoveredProviderId || resolvedProviderId), provider, endpointType, + apiProtocol, requestUrl, + chatCompletionsUrl: + endpointType === 'chat' + ? resolveChatOrCompletionsUrlFromProvider( + provider, + endpointType, + config, + session, + useLegacyCustomUrlFallback, + ) + : '', apiKey: recoveredProviderId ? resolveRecoveredCustomUrlApiKey(config, recoveredProviderId, resolvedProviderId, session) : getProviderSecret(config, resolvedProviderId, session), diff --git a/src/services/apis/shared.mjs b/src/services/apis/shared.mjs index a425305f1..53a7861f6 100644 --- a/src/services/apis/shared.mjs +++ b/src/services/apis/shared.mjs @@ -75,6 +75,16 @@ export function setAbortController(port, onStop, onDisconnect) { } } +// The outer request owns the listeners; protocol attempts only borrow its context. +export async function withAbortController(port, callback, abortContext) { + const requestAbort = abortContext || setAbortController(port) + try { + return await callback(requestAbort) + } finally { + if (!abortContext) requestAbort.cleanController() + } +} + export function pushRecord(session, question, answer) { const recordLength = session.conversationRecords.length let lastRecord diff --git a/src/utils/fetch-sse.mjs b/src/utils/fetch-sse.mjs index b5c2baca6..145645b87 100644 --- a/src/utils/fetch-sse.mjs +++ b/src/utils/fetch-sse.mjs @@ -5,6 +5,9 @@ export const FETCH_REQUEST_FAILED = 'FETCH_REQUEST_FAILED' export const FETCH_RESPONSE_STREAM_FAILED = 'FETCH_RESPONSE_STREAM_FAILED' export const INVALID_API_ENDPOINT = 'INVALID_API_ENDPOINT' +// Bound non-streaming responses, including prefixes with no identifiable format. +const MAX_BUFFERED_RESPONSE_BYTES = 16 * 1024 * 1024 + function setErrorProperty(err, key, value) { try { err[key] = value @@ -61,7 +64,14 @@ function annotateResponseStreamError(resource, err) { } export async function fetchSSE(resource, options) { - const { onMessage, onStart, onEnd, onError, ...fetchOptions } = options + const { + onMessage, + onStart, + onEnd, + onError, + bufferJsonResponse = false, + ...fetchOptions + } = options if (!getHttpRequestUrl(resource)) { await onError(createInvalidApiEndpointError()) return @@ -106,6 +116,12 @@ export async function fetchSSE(resource, options) { await onError(annotateResponseStreamError(resource, err)) } let hasStarted = false + // Opt-in JSON responses are decoded across reads and parsed only at EOF. + const jsonDecoder = bufferJsonResponse ? new TextDecoder() : null + let responseFormat + let jsonText = '' + let pendingChunks = [] + let bufferedBytes = 0 let reader try { reader = resp.body.getReader() @@ -136,21 +152,59 @@ export async function fetchSSE(resource, options) { await handleCallbackError(err) } - let fakeSseData - try { - const commonResponse = JSON.parse(str) - fakeSseData = 'data: ' + JSON.stringify(commonResponse) + '\n\ndata: [DONE]\n\n' - } catch (error) { - console.debug('not common response', error) - } - if (fakeSseData) { + if (!bufferJsonResponse) { + let fakeSseData try { - parser.feed(new TextEncoder().encode(fakeSseData)) - } catch (err) { - await handleCallbackError(err) + const commonResponse = JSON.parse(str) + fakeSseData = 'data: ' + JSON.stringify(commonResponse) + '\n\ndata: [DONE]\n\n' + } catch (error) { + console.debug('not common response', error) } - break + if (fakeSseData) { + try { + parser.feed(new TextEncoder().encode(fakeSseData)) + } catch (err) { + await handleCallbackError(err) + } + break + } + } + } + if (bufferJsonResponse && responseFormat !== 'sse') { + const decodedChunk = jsonDecoder.decode(chunk, { stream: true }) + if (!responseFormat) { + const firstCharacter = decodedChunk.trimStart()[0] + if (firstCharacter) { + responseFormat = firstCharacter === '{' || firstCharacter === '[' ? 'json' : 'sse' + } + if (responseFormat === 'sse') { + try { + for (const pendingChunk of pendingChunks) parser.feed(pendingChunk) + parser.feed(chunk) + } catch (err) { + await handleCallbackError(err) + } + jsonText = '' + pendingChunks = [] + continue + } + } + bufferedBytes += chunk.byteLength + if (bufferedBytes > MAX_BUFFERED_RESPONSE_BYTES) { + jsonText = '' + pendingChunks = [] + const error = new Error('Buffered API response exceeds the 16 MiB limit') + // A failed or stalled cancellation must not hide the original failure. + Promise.resolve() + .then(() => reader.cancel(error)) + .catch(() => {}) + await onError(error) + return } + jsonText += decodedChunk + if (!responseFormat) pendingChunks.push(chunk) + else pendingChunks = [] + continue } try { parser.feed(chunk) @@ -158,5 +212,20 @@ export async function fetchSSE(resource, options) { await handleCallbackError(err) } } + if (bufferJsonResponse && responseFormat !== 'sse') { + let commonResponse + try { + commonResponse = JSON.parse(jsonText + jsonDecoder.decode()) + } catch (err) { + await onError(err) + return + } + try { + await onMessage(JSON.stringify(commonResponse)) + await onMessage('[DONE]') + } catch (err) { + await handleCallbackError(err) + } + } await onEnd() } diff --git a/tests/unit/config/migrate-user-config.test.mjs b/tests/unit/config/migrate-user-config.test.mjs index e4104b02d..0c2e96386 100644 --- a/tests/unit/config/migrate-user-config.test.mjs +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -318,6 +318,114 @@ test('getUserConfig persists provider lineage and path normalization by itself', assert.deepEqual(storedProvider.legacyProviderIds, ['old-id']) }) +for (const { name, storedFields, normalizedFields, expectedWrites } of [ + { + name: 'mixed-case Responses protocol', + storedFields: { apiProtocol: 'ReSpOnSeS' }, + normalizedFields: { apiProtocol: 'responses' }, + expectedWrites: 1, + }, + { + name: 'Responses protocol whitespace', + storedFields: { apiProtocol: ' responses \t' }, + normalizedFields: { apiProtocol: 'responses' }, + expectedWrites: 1, + }, + { + name: 'Responses URL whitespace', + storedFields: { responsesUrl: ' https://proxy.example.com/v1/Responses?key=AbC \t' }, + normalizedFields: { responsesUrl: 'https://proxy.example.com/v1/Responses?key=AbC' }, + expectedWrites: 1, + }, + { + name: 'whitespace-only Responses fields', + storedFields: { apiProtocol: ' \t', responsesUrl: ' \t' }, + normalizedFields: {}, + expectedWrites: 1, + }, + { + name: 'empty Responses fields', + storedFields: { apiProtocol: '', responsesUrl: '' }, + normalizedFields: {}, + expectedWrites: 1, + }, + { + name: 'explicit chat protocol', + storedFields: { apiProtocol: 'chat' }, + normalizedFields: { apiProtocol: 'chat' }, + expectedWrites: 0, + }, + { + name: 'mixed-case Chat protocol whitespace', + storedFields: { apiProtocol: ' ChAt \t' }, + normalizedFields: { apiProtocol: 'chat' }, + expectedWrites: 1, + }, + { + name: 'unrecognized protocol inheritance', + storedFields: { apiProtocol: 'default' }, + normalizedFields: {}, + expectedWrites: 1, + }, + { + name: 'absent Responses fields', + storedFields: {}, + normalizedFields: {}, + expectedWrites: 0, + }, + { + name: 'normalized Responses fields', + storedFields: { + apiProtocol: 'responses', + responsesUrl: 'https://proxy.example.com/v1/Responses?key=AbC', + }, + normalizedFields: { + apiProtocol: 'responses', + responsesUrl: 'https://proxy.example.com/v1/Responses?key=AbC', + }, + expectedWrites: 0, + }, +]) { + test(`getUserConfig persists ${name} normalization only once`, async (t) => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + customOpenAIProviders: [ + { + id: 'proxy', + name: 'Proxy', + baseUrl: 'https://proxy.example.com', + }, + ], + }) + // Finish unrelated migrations so only the Responses fields can trigger a write. + await getUserConfig() + const provider = globalThis.__TEST_BROWSER_SHIM__.getStorage().customOpenAIProviders[0] + globalThis.__TEST_BROWSER_SHIM__.setStorage({ + customOpenAIProviders: [{ ...provider, ...storedFields }], + }) + const setMock = t.mock.method(Browser.storage.local, 'set') + const expectedProviders = [{ ...provider, ...normalizedFields }] + + const firstConfig = await getUserConfig() + + assert.deepEqual(firstConfig.customOpenAIProviders, expectedProviders) + assert.deepEqual( + globalThis.__TEST_BROWSER_SHIM__.getStorage().customOpenAIProviders, + expectedProviders, + ) + assert.equal(setMock.mock.callCount(), expectedWrites) + if (expectedWrites) { + assert.deepEqual(setMock.mock.calls[0].arguments, [ + { customOpenAIProviders: expectedProviders }, + ]) + } + + const secondConfig = await getUserConfig() + + assert.deepEqual(secondConfig.customOpenAIProviders, expectedProviders) + assert.equal(setMock.mock.callCount(), expectedWrites) + }) +} + test('getUserConfig remaps preserved custom sourceProviderId when provider ids are renamed', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ configSchemaVersion: 0, diff --git a/tests/unit/popup/provider-responses-edit.test.mjs b/tests/unit/popup/provider-responses-edit.test.mjs new file mode 100644 index 000000000..724aee194 --- /dev/null +++ b/tests/unit/popup/provider-responses-edit.test.mjs @@ -0,0 +1,658 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { getUserConfig, setUserConfig } from '../../../src/config/index.mjs' +import { + buildEditedProvider, + buildProviderDraft, + parseChatCompletionsEndpointUrl, + validateProviderEndpointDraft, + validateResponsesEndpointDraft, + validateProviderResponsesEndpointDraft, +} from '../../../src/popup/sections/api-modes-provider-utils.mjs' +import { generateAnswersWithOpenAICompatibleApi } from '../../../src/services/apis/openai-api.mjs' +import { resolveOpenAICompatibleRequest } from '../../../src/services/apis/provider-registry.mjs' +import { createFakePort } from '../helpers/port.mjs' +import { createMockSseResponse } from '../helpers/sse-response.mjs' + +const oldResponsesUrl = 'https://old-responses.example/custom/respond?version=1' +const newResponsesUrl = 'https://new-responses.example/custom/respond/?next=a?b/' +const newChatUrl = 'https://new-chat.example/v2/chat/completions?version=2' +const providerSecrets = { 'editable-provider': 'test-provider-key', unrelated: 'test-other-key' } + +function createProvider(overrides = {}) { + return { + id: 'editable-provider', + name: 'Editable Provider', + baseUrl: 'https://base.example', + chatCompletionsPath: '/custom/chat', + completionsPath: '/custom/completions', + chatCompletionsUrl: 'https://old-chat.example/custom/chat?version=1', + completionsUrl: 'https://completion.example/custom/complete?version=1', + apiProtocol: 'responses', + responsesUrl: oldResponsesUrl, + legacyProviderIds: ['previous-provider'], + sourceProviderId: 'openai', + enabled: true, + allowLegacyResponseField: false, + ...overrides, + } +} + +function createSession() { + return { + modelName: 'customModel', + conversationRecords: [], + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'editable-provider', + customName: 'gpt-5', + customUrl: 'https://stale-session.example/v1/chat/completions', + active: true, + }, + } +} + +function editProvider(provider, changes = {}) { + const draft = { ...buildProviderDraft(provider), ...changes } + const { valid, parsedEndpoint } = validateProviderEndpointDraft(draft.apiUrl, draft) + assert.equal(valid, true) + assert.equal(validateProviderResponsesEndpointDraft(draft, provider).valid, true) + return buildEditedProvider(provider, provider.id, draft.name, parsedEndpoint, draft.apiUrl, draft) +} + +async function saveAndReload(provider) { + await setUserConfig({ customOpenAIProviders: [provider], providerSecrets }) + const config = await getUserConfig() + assert.deepEqual(config.providerSecrets, providerSecrets) + assert.deepEqual(globalThis.__TEST_BROWSER_SHIM__.getStorage().providerSecrets, providerSecrets) + return config +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +for (const responsesUrl of [ + 'not a URL', + '/relative/responses', + 'javascript:alert(1)', + 'https://user:password@example.com/responses', + 'https://example.com/responses#fragment', +]) { + test(`Chat edits preserve an unchanged inactive legacy Responses endpoint: ${responsesUrl}`, async () => { + const provider = createProvider({ apiProtocol: 'chat', responsesUrl }) + const draft = { ...buildProviderDraft(provider), name: 'Renamed', apiUrl: newChatUrl } + assert.equal(validateResponsesEndpointDraft(responsesUrl).valid, false) + assert.deepEqual(validateProviderResponsesEndpointDraft(draft, provider), { + valid: true, + responsesUrl, + }) + const updated = editProvider(provider, draft) + const config = await saveAndReload(updated) + const saved = config.customOpenAIProviders.find(({ id }) => id === provider.id) + assert.equal(saved.name, 'Renamed') + assert.equal(saved.responsesUrl, responsesUrl) + assert.equal(saved.chatCompletionsUrl, newChatUrl) + assert.equal(resolveOpenAICompatibleRequest(config, createSession()).requestUrl, newChatUrl) + assert.equal(provider.name, 'Editable Provider') + assert.equal(provider.responsesUrl, responsesUrl) + }) +} + +test('provider Responses validation rejects new invalid values and unsafe protocol switches', () => { + const provider = createProvider({ apiProtocol: 'chat', responsesUrl: 'legacy invalid URL' }) + for (const apiProtocol of ['responses', 'default', undefined]) { + assert.equal( + validateProviderResponsesEndpointDraft( + { ...buildProviderDraft(provider), apiProtocol }, + provider, + ).valid, + false, + ) + } + for (const existingProvider of [ + undefined, + {}, + createProvider({ responsesUrl: newResponsesUrl }), + ]) { + assert.equal( + validateProviderResponsesEndpointDraft(buildProviderDraft(provider), existingProvider).valid, + false, + ) + } + assert.equal( + validateProviderResponsesEndpointDraft( + { ...buildProviderDraft(provider), responsesUrl: 'new invalid URL' }, + provider, + ).valid, + false, + ) + for (const responsesUrl of ['', newResponsesUrl]) { + assert.deepEqual( + validateProviderResponsesEndpointDraft( + { ...buildProviderDraft(provider), responsesUrl }, + provider, + ), + { valid: true, responsesUrl }, + ) + } +}) + +test('Chat legacy exemption does not relax Chat URL validation or silently erase dormant data', () => { + const provider = createProvider({ apiProtocol: 'responses', responsesUrl: 'legacy invalid URL' }) + const draft = { ...buildProviderDraft(provider), apiProtocol: ' CHAT ' } + assert.equal(validateProviderResponsesEndpointDraft(draft, provider).valid, true) + assert.equal(validateProviderEndpointDraft('', draft).valid, false) + assert.equal(validateProviderEndpointDraft('not a chat URL', draft).valid, false) + assert.equal(editProvider(provider, draft).responsesUrl, provider.responsesUrl) + assert.equal( + Object.hasOwn(editProvider(provider, { ...draft, responsesUrl: '' }), 'responsesUrl'), + false, + ) +}) + +test('buildProviderDraft supplies blank fields and inherits the protocol for a new provider', () => { + assert.deepEqual(buildProviderDraft(), { + name: '', + apiUrl: '', + apiProtocol: 'default', + responsesUrl: '', + }) +}) + +test('buildProviderDraft resolves Chat separately and trims only the explicit Responses URL', () => { + const provider = createProvider({ responsesUrl: ` \t${newResponsesUrl}\n ` }) + assert.deepEqual(buildProviderDraft(provider), { + name: 'Editable Provider', + apiUrl: 'https://old-chat.example/custom/chat?version=1', + apiProtocol: 'responses', + responsesUrl: newResponsesUrl, + }) +}) + +for (const [apiProtocol, expected] of [ + [undefined, 'default'], + ['', 'default'], + ['default', 'default'], + ['chat', 'chat'], + [' CHAT ', 'chat'], + ['responses', 'responses'], + [' RESPONSES ', 'responses'], + ['unsupported', 'default'], +]) { + test(`buildProviderDraft normalizes protocol ${JSON.stringify( + apiProtocol, + )} without materializing a derived URL`, () => { + assert.deepEqual( + buildProviderDraft({ + name: 'Base Provider', + baseUrl: 'https://base.example/v1', + apiProtocol, + }), + { + name: 'Base Provider', + apiUrl: 'https://base.example/v1/chat/completions', + apiProtocol: expected, + responsesUrl: '', + }, + ) + }) +} + +test('buildProviderDraft resolves custom Chat paths and treats whitespace Responses as derived', () => { + assert.deepEqual( + buildProviderDraft({ + name: 'Custom Paths', + baseUrl: 'https://base.example', + chatCompletionsPath: '/custom/chat?version=1', + apiProtocol: 'responses', + responsesUrl: ' \t\n ', + }), + { + name: 'Custom Paths', + apiUrl: 'https://base.example/custom/chat?version=1', + apiProtocol: 'responses', + responsesUrl: '', + }, + ) +}) + +test('validateResponsesEndpointDraft accepts blank input for runtime derivation', () => { + for (const value of [undefined, '', ' \t\n ']) { + assert.deepEqual(validateResponsesEndpointDraft(value), { valid: true, responsesUrl: '' }) + } +}) + +test('validateProviderEndpointDraft accepts blank Chat only with an explicit valid Responses endpoint', () => { + for (const value of ['', ' \t\n ']) { + const result = validateProviderEndpointDraft(value, { + apiProtocol: 'responses', + responsesUrl: ` ${newResponsesUrl} `, + }) + assert.equal(result.valid, true) + assert.equal(result.parsedEndpoint.chatCompletionsUrl, '') + assert.equal(result.parsedEndpoint.completionsUrl, '') + } +}) + +for (const apiProtocol of [undefined, 'default', 'chat', 'unsupported']) { + test(`blank Chat is invalid with protocol ${JSON.stringify( + apiProtocol, + )} even with Responses configured`, () => { + assert.equal( + validateProviderEndpointDraft('', { apiProtocol, responsesUrl: newResponsesUrl }).valid, + false, + ) + }) +} + +test('blank Chat remains invalid without a protocol draft or a valid nonempty Responses URL', () => { + assert.equal(validateProviderEndpointDraft('').valid, false) + for (const responsesUrl of [ + undefined, + '', + ' \t\n ', + 'not a URL', + 'ftp://responses.example/respond', + 'https://user:password@responses.example/respond', + 'https://responses.example/respond#fragment', + ]) { + assert.equal( + validateProviderEndpointDraft('', { apiProtocol: 'responses', responsesUrl }).valid, + false, + String(responsesUrl), + ) + } +}) + +test('an explicit Responses endpoint never makes an invalid nonempty Chat URL valid', () => { + for (const apiUrl of [ + 'not a URL', + '/v1/chat/completions', + 'https://chat.example/v1', + 'ftp://chat.example/v1/chat/completions', + 'https://user:password@chat.example/v1/chat/completions', + 'https://chat.example/v1/chat/completions#fragment', + ]) { + assert.equal( + validateProviderEndpointDraft(apiUrl, { + apiProtocol: 'responses', + responsesUrl: newResponsesUrl, + }).valid, + false, + apiUrl, + ) + } + assert.equal(validateProviderEndpointDraft(newChatUrl).valid, true) +}) + +test('editing a Responses-only provider preserves its empty Chat fields and identity after storage', async () => { + const provider = createProvider({ baseUrl: '', chatCompletionsUrl: '', completionsUrl: '' }) + assert.equal(buildProviderDraft(provider).apiUrl, '') + const updated = editProvider(provider, { responsesUrl: ` ${newResponsesUrl} ` }) + assert.deepEqual(updated, { ...provider, responsesUrl: newResponsesUrl }) + const config = await saveAndReload(updated) + const saved = config.customOpenAIProviders.find(({ id }) => id === provider.id) + for (const key of Object.keys(updated)) { + assert.deepEqual(saved[key], updated[key], key) + } + const request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.apiProtocol, 'responses') + assert.equal(request.requestUrl, newResponsesUrl) + assert.equal(request.apiKey, providerSecrets[provider.id]) +}) + +test('clearing Chat while keeping explicit Responses removes the previous Chat endpoint fields', async () => { + const provider = createProvider() + const updated = editProvider(provider, { apiUrl: ' \t ', responsesUrl: newResponsesUrl }) + assert.deepEqual(updated, { + ...provider, + baseUrl: '', + chatCompletionsUrl: '', + completionsUrl: '', + responsesUrl: newResponsesUrl, + }) + const config = await saveAndReload(updated) + assert.equal(buildProviderDraft(config.customOpenAIProviders[0]).apiUrl, '') + const request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.apiProtocol, 'responses') + assert.equal(request.requestUrl, newResponsesUrl) +}) + +test('a Responses-only provider cannot switch to Chat or default while Chat remains blank', () => { + const draft = buildProviderDraft( + createProvider({ baseUrl: '', chatCompletionsUrl: '', completionsUrl: '' }), + ) + assert.equal(validateProviderEndpointDraft(draft.apiUrl, draft).valid, true) + for (const apiProtocol of ['chat', 'default']) { + assert.equal( + validateProviderEndpointDraft(draft.apiUrl, { ...draft, apiProtocol }).valid, + false, + ) + assert.equal(validateProviderEndpointDraft(newChatUrl, { ...draft, apiProtocol }).valid, true) + } +}) + +test('creating a Responses-only provider persists its explicit URL without requiring Chat', async () => { + const draft = { + ...buildProviderDraft(), + name: 'Responses Only', + apiProtocol: 'responses', + responsesUrl: ` ${newResponsesUrl} `, + } + const { valid, parsedEndpoint } = validateProviderEndpointDraft(draft.apiUrl, draft) + assert.equal(valid, true) + const defaults = { + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + enabled: true, + allowLegacyResponseField: true, + } + const provider = buildEditedProvider( + defaults, + 'editable-provider', + draft.name, + parsedEndpoint, + draft.apiUrl, + draft, + ) + assert.deepEqual(provider, { + ...defaults, + id: 'editable-provider', + name: 'Responses Only', + apiProtocol: 'responses', + responsesUrl: newResponsesUrl, + }) + const config = await saveAndReload(provider) + assert.equal(buildProviderDraft(config.customOpenAIProviders[0]).apiUrl, '') + const request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.apiProtocol, 'responses') + assert.equal(request.requestUrl, newResponsesUrl) +}) + +for (const url of [ + 'http://host', + 'https://responses.example/', + 'https://responses.example/v1/', + 'https://responses.example/v2?x=1', + 'https://responses.example/v1/responses', + 'https://responses.example/api/chat', + 'http://localhost:8080/custom/respond', + 'https://responses.example/custom/respond/', + 'https://responses.example/custom/respond/?version=2&next=%2Fchat%2Fcompletions', + 'https://responses.example/custom/respond?next=a?b/', + 'https://RESPONSES.example:443/custom/%72espond/?next=%23anchor/', + newResponsesUrl, +]) { + test(`validateResponsesEndpointDraft preserves the exact trimmed URL: ${url}`, () => { + assert.deepEqual(validateResponsesEndpointDraft(` \t${url}\n `), { + valid: true, + responsesUrl: url, + }) + }) +} + +for (const value of [ + 'not a URL', + '/v1/responses', + '//responses.example/v1/responses', + 'https://', + 'https://bad host.example/v1/responses', + 'ftp://responses.example/v1/responses', + 'file:///v1/responses', + 'javascript:alert(1)', + 'data:text/plain,responses', + 'https://user@responses.example/v1/responses', + 'https://:password@responses.example/v1/responses', + 'https://user:password@responses.example/v1/responses', + 'https://responses.example/v1/responses#fragment', + 'https://responses.example/v1/responses?version=1#fragment', +]) { + test(`validateResponsesEndpointDraft rejects ${value}`, () => { + assert.deepEqual(validateResponsesEndpointDraft(` ${value} `), { + valid: false, + responsesUrl: '', + }) + }) +} + +for (const responsesUrl of ['https://proxy.example/', 'https://proxy.example/v1?version=2']) { + test(`Responses-only provider edits persist the explicit proxy endpoint ${responsesUrl}`, async () => { + const provider = editProvider(createProvider(), { apiUrl: '', responsesUrl }) + const config = await saveAndReload(provider) + const savedProvider = config.customOpenAIProviders[0] + assert.equal(buildProviderDraft(savedProvider).responsesUrl, responsesUrl) + assert.equal(buildProviderDraft(savedProvider).apiUrl, '') + assert.equal(resolveOpenAICompatibleRequest(config, createSession()).requestUrl, responsesUrl) + }) +} + +test('Responses-only edits preserve Chat, base, completions, identity, and secrets after storage', async () => { + const provider = createProvider() + const original = structuredClone(provider) + const updated = editProvider(provider, { responsesUrl: ` ${newResponsesUrl} ` }) + assert.deepEqual(updated, { ...original, responsesUrl: newResponsesUrl }) + assert.deepEqual(provider, original) + + const config = await saveAndReload(updated) + const saved = config.customOpenAIProviders.find(({ id }) => id === provider.id) + for (const key of Object.keys(original)) { + assert.deepEqual(saved[key], updated[key], key) + } + const request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.requestUrl, newResponsesUrl) + assert.equal(request.chatCompletionsUrl, provider.chatCompletionsUrl) + assert.equal(request.apiKey, providerSecrets[provider.id]) +}) + +for (const includeProtocolDraft of [false, true]) { + test(`Chat-only edits preserve explicit Responses with protocol draft ${includeProtocolDraft}`, async () => { + const provider = createProvider() + const original = structuredClone(provider) + const updated = buildEditedProvider( + provider, + provider.id, + provider.name, + parseChatCompletionsEndpointUrl(newChatUrl), + newChatUrl, + includeProtocolDraft ? buildProviderDraft(provider) : undefined, + ) + assert.deepEqual(updated, { + ...original, + baseUrl: '', + chatCompletionsUrl: newChatUrl, + completionsUrl: 'https://new-chat.example/v2/completions?version=2', + }) + assert.deepEqual(provider, original) + const request = resolveOpenAICompatibleRequest(await saveAndReload(updated), createSession()) + assert.equal(request.requestUrl, oldResponsesUrl) + assert.equal(request.chatCompletionsUrl, newChatUrl) + }) +} + +test('clearing Responses removes the property and derives from the newly edited Chat URL', async () => { + const updated = editProvider(createProvider(), { apiUrl: newChatUrl, responsesUrl: ' \t\n ' }) + assert.equal(Object.hasOwn(updated, 'responsesUrl'), false) + const config = await saveAndReload(updated) + assert.equal(Object.hasOwn(config.customOpenAIProviders[0], 'responsesUrl'), false) + assert.equal( + Object.hasOwn( + globalThis.__TEST_BROWSER_SHIM__.getStorage().customOpenAIProviders[0], + 'responsesUrl', + ), + false, + ) + assert.equal(buildProviderDraft(config.customOpenAIProviders[0]).responsesUrl, '') + const request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.apiProtocol, 'responses') + assert.equal(request.requestUrl, 'https://new-chat.example/v2/responses?version=2') + assert.equal(request.chatCompletionsUrl, newChatUrl) +}) + +test('protocol toggles retain the explicit Responses URL and all other provider fields', async () => { + const provider = createProvider() + const chatProvider = editProvider(provider, { apiProtocol: 'chat' }) + const expectedChatProvider = { ...provider, apiProtocol: 'chat' } + assert.deepEqual(chatProvider, expectedChatProvider) + let config = await saveAndReload(chatProvider) + assert.equal(config.customOpenAIProviders[0].apiProtocol, 'chat') + let request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.apiProtocol, 'chat') + assert.equal(request.requestUrl, provider.chatCompletionsUrl) + assert.equal(buildProviderDraft(config.customOpenAIProviders[0]).responsesUrl, oldResponsesUrl) + + const responsesProvider = editProvider(chatProvider, { apiProtocol: 'responses' }) + assert.deepEqual(responsesProvider, provider) + config = await saveAndReload(responsesProvider) + request = resolveOpenAICompatibleRequest(config, createSession()) + assert.equal(request.apiProtocol, 'responses') + assert.equal(request.requestUrl, oldResponsesUrl) +}) + +for (const [apiProtocol, globalProtocol, expectedProtocol] of [ + ['default', 'responses', 'responses'], + ['default', 'chat', 'chat'], + ['chat', 'responses', 'chat'], + ['responses', 'chat', 'responses'], +]) { + test(`provider ${apiProtocol} with global ${globalProtocol} sends ${expectedProtocol} requests after storage`, async (t) => { + t.mock.method(console, 'debug', () => {}) + const provider = createProvider() + const updated = editProvider(provider, { apiProtocol }) + const expected = { ...provider, apiProtocol } + if (apiProtocol === 'default') delete expected.apiProtocol + assert.deepEqual(updated, expected) + await setUserConfig({ openaiApiProtocol: globalProtocol }) + const config = await saveAndReload(updated) + const saved = config.customOpenAIProviders[0] + assert.equal(Object.hasOwn(saved, 'apiProtocol'), apiProtocol !== 'default') + assert.equal(buildProviderDraft(saved).apiProtocol, apiProtocol) + assert.equal( + Object.hasOwn( + globalThis.__TEST_BROWSER_SHIM__.getStorage().customOpenAIProviders[0], + 'apiProtocol', + ), + apiProtocol !== 'default', + ) + const requestedUrls = [] + const bodies = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requestedUrls.push(url) + bodies.push(JSON.parse(init.body)) + return createMockSseResponse( + expectedProtocol === 'responses' + ? [ + 'data: {"type":"response.output_text.delta","delta":"Answer"}\n\n', + 'data: {"type":"response.completed"}\n\n', + ] + : ['data: {"choices":[{"delta":{"content":"Answer"},"finish_reason":"stop"}]}\n\n'], + ) + }) + const session = createSession() + await generateAnswersWithOpenAICompatibleApi(createFakePort(), 'Question', session, config) + assert.deepEqual(requestedUrls, [ + expectedProtocol === 'responses' ? oldResponsesUrl : provider.chatCompletionsUrl, + ]) + assert.equal(Object.hasOwn(bodies[0], 'input'), expectedProtocol === 'responses') + assert.equal(Object.hasOwn(bodies[0], 'messages'), expectedProtocol === 'chat') + assert.deepEqual(session.conversationRecords, [{ question: 'Question', answer: 'Answer' }]) + }) +} + +for (const endpoints of [{}, { chatCompletionsUrl: '', completionsUrl: '' }]) { + test(`rename preserves every endpoint for ${ + endpoints.chatCompletionsUrl === '' ? 'base' : 'explicit' + } providers`, () => { + const provider = createProvider(endpoints) + assert.deepEqual(editProvider(provider, { name: 'Renamed Provider' }), { + ...provider, + name: 'Renamed Provider', + }) + }) +} + +test('renaming a provider without an explicit protocol keeps protocol inheritance', () => { + const provider = createProvider() + delete provider.apiProtocol + const updated = editProvider(provider, { name: 'Renamed Provider' }) + assert.deepEqual(updated, { ...provider, name: 'Renamed Provider' }) + assert.equal(Object.hasOwn(updated, 'apiProtocol'), false) +}) + +for (const [apiProtocol, responsesUrl, expectedUrl] of [ + ['default', '', newChatUrl], + ['chat', '', newChatUrl], + ['responses', '', 'https://new-chat.example/v2/responses?version=2'], + ['responses', newResponsesUrl, newResponsesUrl], +]) { + test(`new ${apiProtocol} providers survive storage with ${ + responsesUrl ? 'explicit' : 'default' + } Responses URLs`, async () => { + const defaults = { + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + enabled: true, + allowLegacyResponseField: true, + } + const provider = buildEditedProvider( + defaults, + 'editable-provider', + 'New Provider', + parseChatCompletionsEndpointUrl(newChatUrl), + newChatUrl, + { ...buildProviderDraft(), apiProtocol, responsesUrl }, + ) + assert.deepEqual(provider, { + ...defaults, + id: 'editable-provider', + name: 'New Provider', + chatCompletionsUrl: newChatUrl, + completionsUrl: 'https://new-chat.example/v2/completions?version=2', + ...(apiProtocol !== 'default' ? { apiProtocol } : {}), + ...(responsesUrl ? { responsesUrl } : {}), + }) + const request = resolveOpenAICompatibleRequest(await saveAndReload(provider), createSession()) + assert.equal(request.apiProtocol, apiProtocol === 'default' ? 'chat' : apiProtocol) + assert.equal(request.requestUrl, expectedUrl) + }) +} + +for (const fallback of [false, true]) { + test(`saved endpoint edits send only to new URLs${ + fallback ? ' with configured Chat fallback on 404' : '' + }`, async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + const provider = editProvider(createProvider(), { + apiUrl: newChatUrl, + responsesUrl: newResponsesUrl, + }) + const config = await saveAndReload(provider) + const session = createSession() + const requestedUrls = [] + t.mock.method(globalThis, 'fetch', async (url) => { + requestedUrls.push(url) + if (requestedUrls.length === 1) { + assert.equal(url, newResponsesUrl) + if (fallback) { + return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }) + } + return createMockSseResponse([ + 'data: {"type":"response.output_text.delta","delta":"New answer"}\n\n', + 'data: {"type":"response.completed"}\n\n', + ]) + } + assert.equal(url, newChatUrl) + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"New answer"},"finish_reason":"stop"}]}\n\n', + ]) + }) + await generateAnswersWithOpenAICompatibleApi(createFakePort(), 'Question', session, config) + assert.deepEqual(requestedUrls, fallback ? [newResponsesUrl, newChatUrl] : [newResponsesUrl]) + assert.deepEqual(session.conversationRecords, [{ question: 'Question', answer: 'New answer' }]) + }) +} diff --git a/tests/unit/services/apis/azure-openai-temperature.test.mjs b/tests/unit/services/apis/azure-openai-temperature.test.mjs index 00bf3e5ec..a1e44adf2 100644 --- a/tests/unit/services/apis/azure-openai-temperature.test.mjs +++ b/tests/unit/services/apis/azure-openai-temperature.test.mjs @@ -38,3 +38,43 @@ test('Azure temperature override does not treat deployment aliases as canonical const body = JSON.parse(capturedInit.body) assert.equal(body.temperature, 0.9) }) + +for (const fallback of [false, true]) { + for (const temperatureOverrideEnabled of [false, true]) { + test(`Azure Responses preserves opaque deployment temperature policy (fallback=${fallback}, override=${temperatureOverrideEnabled})`, async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + azureEndpoint: 'https://myinstance.openai.azure.com', + azureApiKey: 'az-key', + azureDeploymentName: 'gemini-4-flash', + azureUseResponses: true, + maxConversationContextLength: 3, + maxResponseTokenLength: 128, + temperatureOverrideEnabled, + temperature: 0.9, + }) + const session = { modelName: 'azureOpenAi', conversationRecords: [], isRetry: false } + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url, body: JSON.parse(init.body) }) + if (fallback && requests.length === 1) return new Response('Not Found', { status: 404 }) + return createMockSseResponse([ + fallback + ? 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n' + : 'data: {"type":"response.completed","response":{"output_text":"OK"}}\n\n', + ]) + }) + + await generateAnswersWithAzureOpenaiApi(createFakePort(), 'Q', session) + + assert.equal(requests.length, fallback ? 2 : 1) + assert.equal(requests[0].body.model, 'gemini-4-flash') + for (const { body } of requests) { + assert.equal(Object.hasOwn(body, 'temperature'), temperatureOverrideEnabled) + if (temperatureOverrideEnabled) assert.equal(body.temperature, 0.9) + } + assert.equal(session.conversationRecords.length, 1) + }) + } +} diff --git a/tests/unit/services/apis/openai-cancellation-lifecycle.test.mjs b/tests/unit/services/apis/openai-cancellation-lifecycle.test.mjs new file mode 100644 index 000000000..72630812c --- /dev/null +++ b/tests/unit/services/apis/openai-cancellation-lifecycle.test.mjs @@ -0,0 +1,464 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import Browser from 'webextension-polyfill' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' +import { generateAnswersWithAzureOpenaiApi } from '../../../../src/services/apis/azure-openai-api.mjs' +import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { generateAnswersWithOpenAIResponses } from '../../../../src/services/apis/openai-responses-core.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const testOptions = { timeout: 3000 } +const question = 'Question' +const stopGenerationId = 23 +const finalChunk = 'data: {"choices":[{"delta":{"content":"Fallback"},"finish_reason":"stop"}]}\n\n' + +beforeEach((t) => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) +}) + +function deferred(t) { + const gate = Promise.withResolvers() + t.after(() => gate.resolve()) + return gate +} + +function fixture(provider, responses = true) { + const config = { + maxConversationContextLength: 3, + maxResponseTokenLength: 128, + temperatureOverrideEnabled: false, + temperature: 1, + azureEndpoint: 'https://lifecycle.openai.azure.com', + azureApiKey: 'test-key', + azureDeploymentName: 'gpt-4o', + azureUseResponses: responses, + customOpenAIProviders: [ + { + id: 'lifecycle', + name: 'Lifecycle', + baseUrl: 'https://lifecycle.example/v1', + chatCompletionsPath: '/chat/completions', + apiProtocol: responses ? 'responses' : 'chat', + }, + ], + providerSecrets: { lifecycle: 'test-key' }, + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(config) + const session = { + modelName: provider === 'Azure' ? 'azureOpenAi' : 'customModel', + conversationRecords: [], + isRetry: false, + ...(provider === 'Azure' + ? {} + : { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'lifecycle', + customName: 'gpt-4o', + }, + }), + } + const port = createFakePort() + const start = (runtimeConfig = config) => + (provider === 'Azure' + ? generateAnswersWithAzureOpenaiApi(port, question, session) + : generateAnswersWithOpenAICompatibleApi(port, question, session, runtimeConfig) + ).then( + () => ({ error: undefined }), + (error) => ({ error }), + ) + return { config, session, port, start } +} + +async function reach(gate, request) { + // Fail promptly if routing exits before reaching the expected asynchronous boundary. + await Promise.race([ + gate.promise, + request.then(({ error }) => { + throw error || new Error('Request completed before reaching the deferred boundary') + }), + ]) +} + +function cancel(port, action) { + if (action === 'stop') port.emitMessage({ stop: true, stopGenerationId }) + else port.emitDisconnect() +} + +function assertClean(port) { + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +} + +function assertOnlyStopAcknowledgement(port, action) { + assert.deepEqual( + port.postedMessages.filter((message) => message.done), + action === 'stop' ? [{ done: true, stoppedGenerationId: stopGenerationId }] : [], + ) +} + +function unsupportedResponse() { + return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }) +} + +function pausedStream(t, chunks) { + const entered = deferred(t) + const release = deferred(t) + const response = createMockSseResponse(chunks) + const reader = response.body.getReader() + const originalRead = reader.read.bind(reader) + let reads = 0 + t.mock.method(reader, 'read', async () => { + if (++reads === 2) { + entered.resolve() + if ((await release.promise) === 'AbortError') { + throw new DOMException('The operation was aborted', 'AbortError') + } + } + return originalRead() + }) + t.mock.method(response.body, 'getReader', () => reader) + return { response, entered, release } +} + +for (const provider of ['OpenAI-compatible', 'Azure']) { + test( + `${provider}: failed HTTP fallback rejects without retrying and cleans listeners`, + testOptions, + async (t) => { + const { port, session, start } = fixture(provider) + const signals = [] + t.mock.method(globalThis, 'fetch', async (_url, init) => { + signals.push(init.signal) + return signals.length === 1 + ? unsupportedResponse() + : createMockSseResponse([], { + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ error: { message: 'Chat fallback unavailable' } }), + }) + }) + + const { error } = await start() + assert.ok(error instanceof Error) + assert.match(error.message, /Chat fallback unavailable/) + assert.equal(signals.length, 2, 'a failed Chat fallback must not trigger a third fetch') + assert.strictEqual(signals[0], signals[1]) + assert.equal(signals[0].aborted, false) + assert.deepEqual(session.conversationRecords, []) + assertClean(port) + cancel(port, 'stop') + assert.deepEqual(port.postedMessages, []) + }, + ) + + for (const [action, format] of ['stop', 'disconnect'].flatMap((action) => + ['JSON', 'text'].map((format) => [action, format]), + )) { + test( + `${provider}: ${action} during initial 404 ${format} prevents fallback`, + testOptions, + async (t) => { + const { port, session, start } = fixture(provider) + const bodyEntered = deferred(t) + const body = deferred(t) + const calls = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, signal: init.signal }) + if (calls.length > 1) return createMockSseResponse([finalChunk]) + const response = new Response(null, { status: 404 }) + t.mock.method(response, 'text', () => { + bodyEntered.resolve() + return body.promise + }) + return response + }) + const request = start() + await reach(bodyEntered, request) + cancel(port, action) + const abortedWhilePending = calls[0].signal.aborted + body.resolve( + format === 'JSON' ? JSON.stringify({ error: { message: 'Not found' } }) : 'Not found', + ) + const result = await request + + assert.equal( + abortedWhilePending, + true, + 'the original signal must abort while the HTTP body is pending', + ) + assert.equal(result.error, undefined) + assert.equal(calls.length, 1, 'cancellation must prevent the Chat fallback fetch') + assert.deepEqual(session.conversationRecords, []) + assertOnlyStopAcknowledgement(port, action) + assert.equal( + port.postedMessages.some((message) => message.answer || message.session), + false, + ) + assertClean(port) + }, + ) + } +} + +for (const [protocol, generate] of [ + ['Chat', generateAnswersWithOpenAICompatible], + ['Responses', generateAnswersWithOpenAIResponses], +]) { + test( + `${protocol} core: owned listeners are cleaned when request preparation throws`, + testOptions, + async (t) => { + const port = createFakePort() + const session = { conversationRecords: [] } + const failure = new Error('Request configuration unavailable') + let listenersDuringPreparation + const config = { + get maxConversationContextLength() { + listenersDuringPreparation = port.listenerCounts() + throw failure + }, + } + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([finalChunk]), + ) + + await assert.rejects( + generate({ + port, + question, + session, + config, + endpointType: 'chat', + requestUrl: 'https://lifecycle.example/v1/chat/completions', + model: 'gpt-4o', + apiKey: 'test-key', + }), + (error) => error === failure, + ) + assert.deepEqual(listenersDuringPreparation, { onMessage: 1, onDisconnect: 1 }) + assert.equal(fetchMock.mock.callCount(), 0) + assert.deepEqual(session.conversationRecords, []) + assertClean(port) + cancel(port, 'stop') + assert.deepEqual(port.postedMessages, []) + }, + ) +} + +for (const responses of [true, false]) { + for (const action of ['stop', 'disconnect']) { + test( + `Azure: ${action} during initial config read with responses=${responses}`, + testOptions, + async (t) => { + const { port, session, start } = fixture('Azure', responses) + const configEntered = deferred(t) + const configGate = deferred(t) + const originalGet = Browser.storage.local.get.bind(Browser.storage.local) + t.mock.method(Browser.storage.local, 'get', async (...args) => { + configEntered.resolve() + await configGate.promise + return originalGet(...args) + }) + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([finalChunk]), + ) + const request = start() + await reach(configEntered, request) + cancel(port, action) + configGate.resolve() + const result = await request + + assert.equal(result.error, undefined) + assert.equal( + fetchMock.mock.callCount(), + 0, + 'cancellation during config must prevent all fetches', + ) + assert.deepEqual(session.conversationRecords, []) + assertOnlyStopAcknowledgement(port, action) + assert.equal( + port.postedMessages.some((message) => message.answer || message.session), + false, + ) + assertClean(port) + }, + ) + } +} + +for (const provider of ['OpenAI-compatible', 'Azure']) { + test( + `${provider}: normal 404 fallback shares one live signal and completes`, + testOptions, + async (t) => { + const { port, session, start } = fixture(provider) + const calls = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, signal: init.signal, listeners: port.listenerCounts() }) + return calls.length === 1 ? unsupportedResponse() : createMockSseResponse([finalChunk]) + }) + assert.equal((await start()).error, undefined) + assert.equal(calls.length, 2) + assert.match(calls[0].url, /\/responses(?:\?|$)/) + assert.match(calls[1].url, /\/chat\/completions(?:\?|$)/) + assert.strictEqual(calls[0].signal, calls[1].signal) + assert.equal(calls[0].signal.aborted, false) + for (const call of calls) { + assert.deepEqual(call.listeners, { onMessage: 1, onDisconnect: 1 }) + } + assert.deepEqual(session.conversationRecords, [{ question, answer: 'Fallback' }]) + assert.ok(port.postedMessages.some((message) => message.done && message.session === session)) + assertClean(port) + }, + ) + + for (const action of ['stop', 'disconnect']) { + for (const ending of ['AbortError', 'late completion']) { + test( + `${provider}: ${action} during fallback stream handles ${ending}`, + testOptions, + async (t) => { + const { port, session, start } = fixture(provider) + const stream = pausedStream(t, [ + 'data: {"choices":[{"delta":{"content":"Partial"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":" late"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', + ]) + const signals = [] + t.mock.method(globalThis, 'fetch', async (_url, init) => { + signals.push(init.signal) + return signals.length === 1 ? unsupportedResponse() : stream.response + }) + const request = start() + await reach(stream.entered, request) + assert.deepEqual(port.postedMessages, [{ answer: 'Partial', done: false, session: null }]) + cancel(port, action) + stream.release.resolve(ending) + assert.equal((await request).error, undefined) + + assert.equal(signals.length, 2) + assert.strictEqual(signals[0], signals[1]) + assert.equal(signals[0].aborted, true) + assert.deepEqual( + port.postedMessages.filter((message) => typeof message.answer === 'string'), + [{ answer: 'Partial', done: false, session: null }], + ) + // Azure Chat has never persisted unfinished answers; the compatible core does. + assert.deepEqual( + session.conversationRecords, + provider === 'Azure' ? [] : [{ question, answer: 'Partial' }], + ) + assert.deepEqual( + port.postedMessages.filter((message) => message.session), + provider === 'Azure' + ? [] + : [ + { + session, + ...(action === 'stop' ? { stoppedGenerationId: stopGenerationId } : {}), + }, + ], + ) + assertOnlyStopAcknowledgement(port, action) + assertClean(port) + }, + ) + } + + test( + `${provider}: ${action} ignores late Responses events and preserves partial text`, + testOptions, + async (t) => { + const { port, session, start } = fixture(provider) + const stream = pausedStream(t, [ + 'data: {"type":"response.output_text.delta","delta":"Partial"}\n\n', + 'data: {"type":"response.output_text.delta","delta":" late"}\n\ndata: {"type":"response.completed","response":{"output_text":"Partial late"}}\n\ndata: [DONE]\n\n', + ]) + let signal + const fetchMock = t.mock.method(globalThis, 'fetch', async (_url, init) => { + signal = init.signal + return stream.response + }) + const request = start() + await reach(stream.entered, request) + assert.deepEqual(port.postedMessages, [{ answer: 'Partial', done: false, session: null }]) + cancel(port, action) + stream.release.resolve() + assert.equal((await request).error, undefined) + + assert.equal(signal.aborted, true) + assert.equal(fetchMock.mock.callCount(), 1) + assert.deepEqual(session.conversationRecords, [{ question, answer: 'Partial' }]) + assert.deepEqual( + port.postedMessages.filter((message) => typeof message.answer === 'string'), + [{ answer: 'Partial', done: false, session: null }], + ) + assertOnlyStopAcknowledgement(port, action) + assertClean(port) + }, + ) + } + + for (const responses of [true, false]) { + test( + `${provider}: config rejection cleans outer listeners with responses=${responses}`, + testOptions, + async (t) => { + const { port, session, start } = fixture(provider, responses) + const failure = new Error('Storage unavailable') + const configEntered = deferred(t) + const configGate = deferred(t) + t.mock.method(Browser.storage.local, 'get', async () => { + configEntered.resolve() + await configGate.promise + throw failure + }) + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([finalChunk]), + ) + const request = start(null) + await reach(configEntered, request) + const listenersWhilePending = port.listenerCounts() + configGate.resolve() + assert.strictEqual((await request).error, failure) + assert.deepEqual(listenersWhilePending, { onMessage: 1, onDisconnect: 1 }) + assert.equal(fetchMock.mock.callCount(), 0) + assert.deepEqual(session.conversationRecords, []) + assertClean(port) + cancel(port, 'stop') + assert.deepEqual(port.postedMessages, [], 'failed requests must not leave a stop listener') + }, + ) + + test( + `${provider}: preflight failure cleans listeners with responses=${responses}`, + testOptions, + async (t) => { + const { config, port, session, start } = fixture(provider, responses) + if (provider === 'Azure') { + // Invalid persisted settings fail URL construction before fetch. + globalThis.__TEST_BROWSER_SHIM__.setStorage({ azureEndpoint: 42 }) + } else { + config.customOpenAIProviders[0].enabled = false + } + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([finalChunk]), + ) + const { error } = await start() + assert.ok(error instanceof Error) + assert.match(error.message, provider === 'Azure' ? /replace/ : /Failed to resolve/) + assert.equal(fetchMock.mock.callCount(), 0) + assert.deepEqual(session.conversationRecords, []) + assertClean(port) + cancel(port, 'stop') + assert.deepEqual(port.postedMessages, []) + }, + ) + } +} diff --git a/tests/unit/services/apis/openai-responses-core.test.mjs b/tests/unit/services/apis/openai-responses-core.test.mjs new file mode 100644 index 000000000..0fe81db27 --- /dev/null +++ b/tests/unit/services/apis/openai-responses-core.test.mjs @@ -0,0 +1,484 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { + applyResponsesStreamEvent, + buildResponsesBody, + buildResponsesInput, + extractResponsesOutputText, + generateAnswersWithOpenAIResponses, + isResponsesRouteUnsupportedError, +} from '../../../../src/services/apis/openai-responses-core.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const setStorage = (values) => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +test('buildResponsesInput maps history pairs and appends the current question', () => { + const input = buildResponsesInput( + [ + { question: 'PrevQ', answer: 'PrevA' }, + { question: 'Q2', answer: 'A2' }, + ], + 'CurrentQ', + 9, + ) + assert.deepEqual(input, [ + { role: 'user', content: 'PrevQ' }, + { role: 'assistant', content: 'PrevA' }, + { role: 'user', content: 'Q2' }, + { role: 'assistant', content: 'A2' }, + { role: 'user', content: 'CurrentQ' }, + ]) +}) + +test('buildResponsesInput respects maxConversationContextLength', () => { + const input = buildResponsesInput([{ question: 'Old', answer: 'OldA' }], 'CurrentQ', 0) + assert.deepEqual(input, [{ role: 'user', content: 'CurrentQ' }]) +}) + +test('buildResponsesBody uses max_output_tokens and drops chat-only keys', () => { + const body = buildResponsesBody({ + model: 'gpt-5.6', + input: [{ role: 'user', content: 'hi' }], + config: { maxResponseTokenLength: 321 }, + extraBody: { + max_tokens: 1, + max_completion_tokens: 2, + messages: [{ role: 'user', content: 'ignored' }], + temperature: 0.1, + stream: false, + }, + }) + assert.equal(body.model, 'gpt-5.6') + assert.equal(body.stream, true) + assert.equal(body.store, false) + assert.equal(body.max_output_tokens, 321) + assert.equal(Object.hasOwn(body, 'max_tokens'), false) + assert.equal(Object.hasOwn(body, 'max_completion_tokens'), false) + assert.equal(Object.hasOwn(body, 'messages'), false) + assert.equal(Object.hasOwn(body, 'temperature'), false) +}) + +test('buildResponsesBody converts response_format to text.format', () => { + const body = buildResponsesBody({ + model: 'gpt-5.6', + input: 'hi', + config: { maxResponseTokenLength: 100 }, + extraBody: { + response_format: { + type: 'json_schema', + name: 'answer', + strict: true, + schema: { type: 'object' }, + }, + }, + }) + assert.deepEqual(body.text, { + format: { + type: 'json_schema', + name: 'answer', + strict: true, + schema: { type: 'object' }, + }, + }) + assert.equal(Object.hasOwn(body, 'response_format'), false) +}) + +for (const { label, fields, expected } of [ + { + label: 'nested description', + fields: { json_schema: { description: 'An answer' } }, + expected: { name: 'response', strict: true, schema: {}, description: 'An answer' }, + }, + { + label: 'empty top-level description takes precedence', + fields: { description: '', json_schema: { description: 'Ignored' } }, + expected: { name: 'response', strict: true, schema: {}, description: '' }, + }, + { + label: 'nested metadata', + fields: { json_schema: { name: 'nested_answer', strict: false, schema: { type: 'object' } } }, + expected: { name: 'nested_answer', strict: false, schema: { type: 'object' } }, + }, + { + label: 'top-level metadata takes precedence', + fields: { + name: 'top_answer', + strict: false, + schema: { type: 'string' }, + json_schema: { name: 'nested_answer', strict: true, schema: { type: 'object' } }, + }, + expected: { name: 'top_answer', strict: false, schema: { type: 'string' } }, + }, + { + label: 'explicit top-level true overrides nested false', + fields: { strict: true, json_schema: { name: 'nested_answer', strict: false } }, + expected: { name: 'nested_answer', strict: true, schema: {} }, + }, + { + label: 'missing metadata keeps defaults', + fields: {}, + expected: { name: 'response', strict: true, schema: {} }, + }, + { + label: 'nullish metadata falls back to nested fields', + fields: { name: '', strict: null, json_schema: { name: 'nested_answer', strict: false } }, + expected: { name: 'nested_answer', strict: false, schema: {} }, + }, +]) { + test(`buildResponsesBody preserves schema contract: ${label}`, () => { + const extraBody = { response_format: { type: 'json_schema', ...fields } } + const original = structuredClone(extraBody) + const body = buildResponsesBody({ model: 'test', input: 'hi', config: {}, extraBody }) + assert.deepEqual(body.text.format, { type: 'json_schema', ...expected }) + assert.equal(Object.hasOwn(body, 'response_format'), false) + assert.deepEqual(extraBody, original) + }) +} + +test('buildResponsesBody preserves explicit text instead of converting response_format', () => { + const text = { format: { type: 'json_object' } } + const body = buildResponsesBody({ + model: 'test', + input: 'hi', + config: {}, + extraBody: { + text, + response_format: { type: 'json_schema', json_schema: { name: 'ignored', strict: false } }, + }, + }) + assert.deepEqual(body.text, text) + assert.equal(Object.hasOwn(body, 'response_format'), false) +}) + +test('extractResponsesOutputText reads output_text and message items', () => { + assert.equal(extractResponsesOutputText({ output_text: 'direct' }), 'direct') + assert.equal( + extractResponsesOutputText({ + output: [ + { type: 'reasoning', summary: [] }, + { + type: 'message', + content: [{ type: 'output_text', text: 'Hello ' }], + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'world' }], + }, + ], + }), + 'Hello world', + ) + assert.equal(extractResponsesOutputText({}), '') +}) + +test('applyResponsesStreamEvent accumulates deltas and finishes on completed', () => { + let result = applyResponsesStreamEvent('', { + type: 'response.output_text.delta', + delta: 'Hel', + }) + assert.equal(result.answer, 'Hel') + assert.equal(result.done, false) + + result = applyResponsesStreamEvent(result.answer, { + type: 'response.output_text.delta', + delta: 'lo', + }) + assert.equal(result.answer, 'Hello') + + result = applyResponsesStreamEvent(result.answer, { type: 'response.completed' }) + assert.equal(result.done, true) + assert.equal(result.answer, 'Hello') +}) + +test('applyResponsesStreamEvent surfaces error events', () => { + const result = applyResponsesStreamEvent('partial', { + type: 'error', + message: 'boom', + error: { message: 'boom' }, + }) + assert.equal(result.failed, true) + assert.match(result.error.message, /boom/) +}) + +test('generateAnswersWithOpenAIResponses streams typed events into one answer', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 256 }) + + const session = { + modelName: 'chatgptApi5_6', + conversationRecords: [{ question: 'PrevQ', answer: 'PrevA' }], + isRetry: false, + } + const port = createFakePort() + + let capturedInput + let capturedInit + t.mock.method(globalThis, 'fetch', async (input, init) => { + capturedInput = input + capturedInit = init + return createMockSseResponse([ + 'data: {"type":"response.created"}\n\n', + 'data: {"type":"response.output_text.delta","delta":"Hel"}\n\n', + 'data: {"type":"response.output_text.delta","delta":"lo"}\n\n', + 'data: {"type":"response.completed"}\n\n', + ]) + }) + + await generateAnswersWithOpenAIResponses({ + port, + question: 'CurrentQ', + session, + requestUrl: 'https://api.openai.com/v1/responses', + model: 'gpt-5.6', + apiKey: 'sk-test', + config: { + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + }, + }) + + assert.equal(capturedInput, 'https://api.openai.com/v1/responses') + const body = JSON.parse(capturedInit.body) + assert.equal(body.model, 'gpt-5.6') + assert.equal(body.stream, true) + assert.equal(body.store, false) + assert.equal(body.max_output_tokens, 256) + assert.deepEqual(body.input.at(-1), { role: 'user', content: 'CurrentQ' }) + assert.equal( + port.postedMessages.some((message) => message.done === false && message.answer === 'Hello'), + true, + ) + assert.deepEqual(port.postedMessages.at(-1), { answer: null, done: true, session }) + assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hello' }) +}) + +for (const transport of ['JSON', 'split JSON', 'SSE']) { + test(`generateAnswersWithOpenAIResponses rejects failed ${transport} responses without saving or finishing`, async (t) => { + t.mock.method(console, 'debug', () => {}) + const records = [{ question: 'PrevQ', answer: 'PrevA' }] + const session = { + modelName: 'chatgptApi5_6', + conversationRecords: [...records], + isRetry: false, + } + const port = createFakePort() + const response = { + id: 'resp_failed', + object: 'response', + status: 'failed', + error: { code: 'server_error', message: 'Provider could not complete the response' }, + output: [], + } + const chunk = + transport !== 'SSE' + ? JSON.stringify(response) + : `data: ${JSON.stringify({ type: 'response.failed', response })}\n\ndata: [DONE]\n\n` + const chunks = transport === 'split JSON' ? [chunk.slice(0, 25), chunk.slice(25)] : [chunk] + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(chunks)) + + await assert.rejects( + generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: 'https://api.openai.com/v1/responses', + model: 'gpt-5.6', + apiKey: 'sk-test', + config: { maxConversationContextLength: 9, maxResponseTokenLength: 100 }, + }), + { message: response.error.message }, + ) + + assert.deepEqual(session.conversationRecords, records) + assert.equal( + port.postedMessages.some((message) => message.done === true), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const { split, answer } of [ + { split: false, answer: 'Hello' }, + { split: true, answer: 'Hello' }, + { split: false, answer: '' }, + { split: true, answer: '' }, +]) { + test(`generateAnswersWithOpenAIResponses validates a completed JSON response before saving (split: ${split}, answer: ${JSON.stringify( + answer, + )})`, async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { modelName: 'chatgptApi5_6', conversationRecords: [], isRetry: false } + const port = createFakePort() + const json = JSON.stringify({ + id: 'resp_completed', + object: 'response', + status: 'completed', + error: null, + output: answer ? [{ type: 'message', content: [{ type: 'output_text', text: answer }] }] : [], + }) + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse(split ? [json.slice(0, 25), json.slice(25)] : [json]), + ) + + const request = generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: 'https://api.openai.com/v1/responses', + model: 'gpt-5.6', + apiKey: 'sk-test', + config: { maxConversationContextLength: 9, maxResponseTokenLength: 100 }, + }) + + if (!answer) { + await assert.rejects(request, { message: 'Responses API completed without output text' }) + assert.deepEqual(session.conversationRecords, []) + assert.equal( + port.postedMessages.some((message) => message.done || message.session), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + return + } + await request + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer }]) + assert.deepEqual( + port.postedMessages.filter((message) => message.done === true), + [{ answer: null, done: true, session }], + ) + }) +} + +test('generateAnswersWithOpenAIResponses saves split JSON with whitespace and UTF-8 once', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { modelName: 'chatgptApi5_6', conversationRecords: [], isRetry: false } + const port = createFakePort() + const answer = 'Hello 臺灣 🌏' + const bytes = new TextEncoder().encode( + ` \r\n\t${JSON.stringify({ status: 'completed', output_text: answer })}`, + ) + t.mock.method( + globalThis, + 'fetch', + async () => + new Response( + new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)) + controller.close() + }, + }), + ), + ) + + await generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: 'https://api.openai.com/v1/responses', + model: 'gpt-5.6', + config: {}, + }) + + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer }]) + assert.equal( + port.postedMessages.some((message) => message.answer === answer), + true, + ) + assert.equal(port.postedMessages.filter((message) => message.done === true).length, 1) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +for (const { name, chunks, error } of [ + { name: 'malformed JSON', chunks: ['{"status":', 'invalid}'], error: SyntaxError }, + { name: 'truncated JSON', chunks: ['{"status":', '"completed"'], error: SyntaxError }, + { name: 'empty body', chunks: [], error: SyntaxError }, + { name: 'whitespace body', chunks: [' \n', '\t'], error: SyntaxError }, +]) { + test(`generateAnswersWithOpenAIResponses rejects ${name} without saving empty success`, async (t) => { + t.mock.method(console, 'debug', () => {}) + const records = [{ question: 'Previous', answer: 'Previous answer' }] + const session = { + modelName: 'chatgptApi5_6', + conversationRecords: [...records], + isRetry: false, + } + const port = createFakePort() + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(chunks)) + + await assert.rejects( + generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: 'https://api.openai.com/v1/responses', + model: 'gpt-5.6', + config: {}, + }), + error, + ) + + assert.deepEqual(session.conversationRecords, records) + assert.equal( + port.postedMessages.some((message) => message.done === true), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +test('generateAnswersWithOpenAIResponses exposes HTTP status on errors', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { modelName: 'chatgptApi5_6', conversationRecords: [], isRetry: false } + const port = createFakePort() + t.mock.method( + globalThis, + 'fetch', + async () => new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }), + ) + + await assert.rejects( + generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: 'https://api.openai.com/v1/responses', + model: 'gpt-5.6', + apiKey: 'sk-test', + config: { maxConversationContextLength: 9, maxResponseTokenLength: 100 }, + }), + (error) => { + assert.equal(error.status, 404) + return true + }, + ) +}) + +test('isResponsesRouteUnsupportedError only matches route-naming failures', () => { + assert.equal(isResponsesRouteUnsupportedError({ status: 404, message: 'x' }), true) + assert.equal( + isResponsesRouteUnsupportedError({ + status: 400, + message: '{"error":{"message":"Unknown URL /v1/responses"}}', + }), + true, + ) + assert.equal( + isResponsesRouteUnsupportedError({ + status: 400, + message: 'The api-version is invalid for /openai/responses', + }), + true, + ) + assert.equal(isResponsesRouteUnsupportedError({ message: 'something unknown failed' }), false) + assert.equal(isResponsesRouteUnsupportedError({ message: 'boom' }), false) + assert.equal(isResponsesRouteUnsupportedError(null), false) +}) diff --git a/tests/unit/services/apis/openai-responses-fallback.test.mjs b/tests/unit/services/apis/openai-responses-fallback.test.mjs new file mode 100644 index 000000000..c4a477ef1 --- /dev/null +++ b/tests/unit/services/apis/openai-responses-fallback.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' +import { generateAnswersWithAzureOpenaiApi } from '../../../../src/services/apis/azure-openai-api.mjs' +import { isResponsesRouteUnsupportedError } from '../../../../src/services/apis/openai-responses-core.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const cases = [ + [404, 'Not found', true], + [400, 'Unknown URL /v1/responses', true], + [400, 'The api-version is invalid for /openai/responses', true], + [400, 'The api-version "2025-01-01" is unsupported for /openai/responses', true], + [400, 'The model is not supported with the Responses API', true], + [400, 'The model "old-model" does not support /v1/responses', true], + [405, 'The endpoint /v1/responses is not supported', true], + [501, 'Unsupported endpoint /openai/responses', true], + [400, 'The Responses API is not supported', true], + [501, 'The Responses API is not supported', true], + [400, 'The model gpt-4o does not support the Responses API', true], + [400, 'The api-version 2025-04-01-preview is invalid for /openai/responses', true], + [401, 'Invalid API key for /v1/responses', false], + [403, 'Access to /v1/responses does not exist for this API key', false], + [429, 'Unknown URL /v1/responses', false], + [500, 'Unknown URL /v1/responses', false], + [503, 'Unsupported endpoint /openai/responses', false], + [400, 'Invalid input schema for /v1/responses', false], + [400, 'Unknown parameter model for /v1/responses', false], + [400, 'Unsupported parameter api-version for /openai/responses', false], + [400, 'The model requires an invalid input schema for /v1/responses', false], + [400, 'The model is supported by /v1/responses but input is invalid', false], + [400, 'The model does not support temperature in the Responses API.', false], + [400, 'The endpoint /v1/responses does not support temperature', false], + [400, 'The Responses API does not support this parameter', false], + [400, 'Previous response does not exist for /v1/responses', false], + [400, 'File not found for /v1/responses', false], + [400, 'Unknown URL /v1/chat/completions', false], + [undefined, 'Unknown URL /v1/responses', false], +] + +beforeEach((t) => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) +}) + +for (const [status, message, fallback] of cases) { + test(`Responses fallback classifier: ${status} ${message}`, () => { + assert.equal(isResponsesRouteUnsupportedError({ status, message }), fallback) + assert.equal( + isResponsesRouteUnsupportedError({ status, message: JSON.stringify({ error: { message } }) }), + fallback, + ) + }) +} + +test('Responses fallback ignores route and error words in unrelated JSON metadata', () => { + assert.equal( + isResponsesRouteUnsupportedError({ + status: 400, + message: JSON.stringify({ + error: { message: 'Invalid input' }, + request: { path: '/v1/responses' }, + hint: 'Unknown URL /v1/responses', + }), + }), + false, + ) +}) + +for (const [provider, format] of ['OpenAI-compatible', 'Azure'].flatMap((provider) => + ['json', 'text'].map((format) => [provider, format]), +)) { + for (const [status, message, fallback] of cases.filter(([status]) => status !== undefined)) { + test(`${provider} ${format} initial HTTP ${status}: ${message}`, async (t) => { + const config = { + azureUseResponses: true, + azureEndpoint: 'https://azure.example', + azureApiKey: 'test-key', + azureDeploymentName: 'gpt-4o', + customOpenAIProviders: [ + { + id: 'fallback-test', + name: 'Fallback test', + apiProtocol: 'responses', + responsesUrl: 'https://responses.example/v1/responses', + chatCompletionsUrl: 'https://chat.example/custom/chat', + }, + ], + providerSecrets: { 'fallback-test': 'test-key' }, + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(config) + const history = [{ question: 'Previous question', answer: 'Previous answer' }] + const session = { + modelName: provider === 'Azure' ? 'azureOpenAi' : 'customModel', + conversationRecords: structuredClone(history), + isRetry: false, + ...(provider === 'Azure' + ? {} + : { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'fallback-test', + customName: 'gpt-4o', + active: true, + }, + }), + } + const port = createFakePort() + const calls = [] + const errorBody = format === 'text' ? message : JSON.stringify({ error: { message } }) + t.mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url, signal: options.signal }) + if (calls.length === 1) return new Response(errorBody, { status }) + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + const request = + provider === 'Azure' + ? generateAnswersWithAzureOpenaiApi(port, 'Question', session) + : generateAnswersWithOpenAICompatibleApi(port, 'Question', session, config) + if (fallback) { + await request + assert.equal(calls.length, 2) + assert.equal( + calls[1].url, + provider === 'Azure' + ? 'https://azure.example/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01' + : 'https://chat.example/custom/chat', + ) + assert.equal(calls[1].signal, calls[0].signal) + assert.deepEqual(session.conversationRecords, [ + ...history, + { question: 'Question', answer: 'Fallback' }, + ]) + } else { + await assert.rejects( + request, + (error) => error.status === status && error.message === errorBody, + ) + assert.equal(calls.length, 1) + assert.deepEqual(session.conversationRecords, history) + assert.equal( + port.postedMessages.some((message) => message.done), + false, + ) + } + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) + } +} diff --git a/tests/unit/services/apis/openai-responses-online-review.test.mjs b/tests/unit/services/apis/openai-responses-online-review.test.mjs new file mode 100644 index 000000000..78769b2f3 --- /dev/null +++ b/tests/unit/services/apis/openai-responses-online-review.test.mjs @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' +import { generateAnswersWithAzureOpenaiApi } from '../../../../src/services/apis/azure-openai-api.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +beforeEach((t) => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) +}) + +function createRequest(provider, endpoints = {}, retry = false) { + const config = { + azureUseResponses: true, + azureEndpoint: 'https://azure.example', + azureApiKey: 'test-key', + azureDeploymentName: 'test-deployment', + customOpenAIProviders: [ + { + id: 'online-review', + name: 'Online review', + apiProtocol: 'responses', + responsesUrl: 'https://responses.example/v1/responses', + ...endpoints, + }, + ], + providerSecrets: { 'online-review': 'test-key' }, + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(config) + const history = [ + { question: retry ? 'Question' : 'Previous question', answer: 'Previous answer' }, + ] + const session = { + modelName: provider === 'Azure' ? 'azureOpenAi' : 'customModel', + conversationRecords: structuredClone(history), + isRetry: retry, + ...(provider === 'Azure' + ? {} + : { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'online-review', + customName: 'gpt-4o', + }, + }), + } + const port = createFakePort() + return { + port, + session, + history, + run: () => + provider === 'Azure' + ? generateAnswersWithAzureOpenaiApi(port, 'Question', session) + : generateAnswersWithOpenAICompatibleApi(port, 'Question', session, config), + } +} + +function assertFailedWithoutSaving({ port, session, history }) { + assert.deepEqual(session.conversationRecords, history) + assert.equal( + port.postedMessages.some((message) => message.done), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +} + +const emptyCompletedJson = JSON.stringify({ status: 'completed', output: [] }) +const emptyCompletedEvent = `data: ${JSON.stringify({ + type: 'response.completed', + response: { status: 'completed', output: [] }, +})}\n\n` +const lateTextEvent = 'data: {"type":"response.output_text.delta","delta":"Late answer"}\n\n' +const doneEvent = 'data: [DONE]\n\n' + +for (const provider of ['OpenAI-compatible', 'Azure']) { + for (const retry of [false, true]) { + for (const [label, chunks] of [ + ['completed JSON', [emptyCompletedJson]], + ['split completed JSON', [emptyCompletedJson.slice(0, 15), emptyCompletedJson.slice(15)]], + ['empty JSON object', ['{}']], + ['bare completion', ['data: {"type":"response.completed"}\n\n', doneEvent]], + ['empty completion', [emptyCompletedEvent, doneEvent]], + ['terminator only', [doneEvent]], + ['empty delta', ['data: {"type":"response.output_text.delta","delta":""}\n\n', doneEvent]], + [ + 'reasoning-only JSON', + [JSON.stringify({ status: 'completed', output: [{ type: 'reasoning', summary: [] }] })], + ], + ['completion before coalesced late text', [emptyCompletedEvent + lateTextEvent + doneEvent]], + ['completion before later text', [emptyCompletedEvent, lateTextEvent, doneEvent]], + ]) { + test(`${provider} rejects ${label} without saving or fallback (retry=${retry})`, async (t) => { + const request = createRequest( + provider, + { chatCompletionsUrl: 'https://chat.example/chat' }, + retry, + ) + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse(chunks), + ) + + await assert.rejects( + request.run(), + (error) => + error.message === 'Responses API completed without output text' && + error.status === undefined, + ) + + assert.equal(fetchMock.mock.callCount(), 1) + assertFailedWithoutSaving(request) + assert.equal(request.session.isRetry, retry) + assert.equal( + request.port.postedMessages.some((message) => message.session || message.answer), + false, + ) + }) + } + + for (const [label, answer, chunks] of [ + [ + 'accumulated text before empty completion', + 'Answer', + [ + 'data: {"type":"response.output_text.delta","delta":"Answer"}\n\n', + emptyCompletedEvent, + doneEvent, + ], + ], + [ + 'refusal JSON', + 'Cannot answer', + [ + JSON.stringify({ + status: 'completed', + output: [{ type: 'message', content: [{ type: 'refusal', refusal: 'Cannot answer' }] }], + }), + ], + ], + ['whitespace JSON', ' \n', [JSON.stringify({ status: 'completed', output_text: ' \n' })]], + ]) { + test(`${provider} retains ${label} (retry=${retry})`, async (t) => { + const request = createRequest(provider, {}, retry) + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse(chunks), + ) + await request.run() + + assert.equal(fetchMock.mock.callCount(), 1) + assert.deepEqual(request.session.conversationRecords, [ + ...(retry ? [] : request.history), + { question: 'Question', answer }, + ]) + assert.equal( + request.port.postedMessages.filter((message) => message.done === true).length, + 1, + ) + assert.deepEqual(request.port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) + } + } +} + +for (const chatCompletionsUrl of [ + 'not a URL', + '/relative/chat', + 'https://[invalid', + 'ftp://chat.example/responses', + 'javascript:alert(1)', +]) { + for (const status of [400, 404, 405, 501]) { + for (const retry of [false, true]) { + test(`Invalid Chat URL ${chatCompletionsUrl} preserves HTTP ${status} (retry=${retry})`, async (t) => { + const request = createRequest('OpenAI-compatible', { chatCompletionsUrl }, retry) + const errorBody = JSON.stringify({ error: { message: 'Responses API is not supported' } }) + const urls = [] + t.mock.method(globalThis, 'fetch', async (url) => { + urls.push(url) + if (urls.length === 1) return new Response(errorBody, { status }) + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Unexpected fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + await assert.rejects( + request.run(), + (error) => error.status === status && error.message === errorBody, + ) + assert.deepEqual(urls, ['https://responses.example/v1/responses']) + assertFailedWithoutSaving(request) + }) + } + } +} + +for (const chatCompletionsUrl of [ + 'https://chat.example/custom/responses', + 'https://CHAT.example:443/custom/ReSpOnSeS?next=a%2Fb&version=1', + 'http://localhost:8000/custom/responses/?version=2', + 'https://chat.example/custom/chat?next=/responses', +]) { + for (const status of [400, 404, 405, 501]) { + for (const retry of [false, true]) { + test(`Chat fallback preserves configured URL ${chatCompletionsUrl} after HTTP ${status} (retry=${retry})`, async (t) => { + const request = createRequest('OpenAI-compatible', { chatCompletionsUrl }, retry) + const calls = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url, signal: options.signal }) + if (calls.length === 1) { + return new Response( + JSON.stringify({ error: { message: 'Responses API is not supported' } }), + { status }, + ) + } + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Fallback answer"},"finish_reason":"stop"}]}\n\n', + ]) + }) + await request.run() + assert.deepEqual( + calls.map(({ url }) => url), + ['https://responses.example/v1/responses', chatCompletionsUrl], + ) + assert.equal(calls[0].signal, calls[1].signal) + assert.equal(calls[1].signal.aborted, false) + assert.equal(request.port.postedMessages.filter((message) => message.done).length, 1) + assert.deepEqual(request.session.conversationRecords, [ + ...(retry ? [] : request.history), + { question: 'Question', answer: 'Fallback answer' }, + ]) + assert.deepEqual(request.port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) + } + } +} + +for (const provider of ['OpenAI-compatible', 'Azure']) { + for (const [partial, retry] of [false, true].flatMap((partial) => + [false, true].map((retry) => [partial, retry]), + )) { + for (const terminal of ['[DONE]', '{"type":"response.completed"}', null]) { + for (const coalesced of [false, true]) { + test(`${provider} rejects malformed SSE before ${ + terminal ?? 'EOF' + } (partial=${partial}, retry=${retry}, coalesced=${coalesced})`, async (t) => { + const request = createRequest( + provider, + { chatCompletionsUrl: 'https://chat.example/custom/chat' }, + retry, + ) + const events = [ + ...(partial + ? ['data: {"type":"response.output_text.delta","delta":"Partial"}\n\n'] + : []), + 'data: {invalid-json}\n\n', + ...(terminal ? [`data: ${terminal}\n\n`] : []), + ] + const calls = [] + t.mock.method(globalThis, 'fetch', async (url) => { + calls.push(url) + return createMockSseResponse(coalesced ? [events.join('')] : events) + }) + + await assert.rejects(request.run(), SyntaxError) + assert.equal(calls.length, 1, 'Malformed SSE must not trigger a Chat request') + assertFailedWithoutSaving(request) + assert.equal(request.session.isRetry, retry) + assert.equal( + request.port.postedMessages.filter((message) => message.answer === 'Partial').length, + partial ? 1 : 0, + ) + }) + } + } + } +} + +for (const responsesUrl of [ + 'https://responses.example/v1/responses', + 'https://responses.example/v1/responses/?api-version=1', + 'https://responses.example/custom/respond?api-version=2', +]) { + for (const status of [400, 404, 405, 501]) { + test(`Responses-only provider preserves HTTP ${status} at ${responsesUrl} without guessing Chat`, async (t) => { + const request = createRequest('OpenAI-compatible', { responsesUrl }) + const errorBody = JSON.stringify({ error: { message: 'Responses API is not supported' } }) + const urls = [] + t.mock.method(globalThis, 'fetch', async (url) => { + urls.push(url) + if (urls.length === 1) return new Response(errorBody, { status }) + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Unexpected fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await assert.rejects( + request.run(), + (error) => error.status === status && error.message === errorBody, + ) + assert.deepEqual(urls, [responsesUrl]) + assertFailedWithoutSaving(request) + }) + } +} diff --git a/tests/unit/services/apis/openai-responses-protocol.test.mjs b/tests/unit/services/apis/openai-responses-protocol.test.mjs new file mode 100644 index 000000000..c6928c9dc --- /dev/null +++ b/tests/unit/services/apis/openai-responses-protocol.test.mjs @@ -0,0 +1,538 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' +import { + deriveChatCompletionsUrlFromResponsesUrl, + deriveResponsesUrlFromChatUrl, + normalizeApiProtocol, + resolveApiProtocolForSession, + resolveOpenAICompatibleRequest, +} from '../../../../src/services/apis/provider-registry.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const setStorage = (values) => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +test('normalizeApiProtocol defaults to chat and accepts responses', () => { + assert.equal(normalizeApiProtocol(undefined), 'chat') + assert.equal(normalizeApiProtocol('chat'), 'chat') + assert.equal(normalizeApiProtocol('responses'), 'responses') + assert.equal(normalizeApiProtocol('RESPONSES'), 'responses') + assert.equal(normalizeApiProtocol('other'), 'chat') +}) + +test('deriveResponsesUrlFromChatUrl replaces chat completions suffix', () => { + assert.equal( + deriveResponsesUrlFromChatUrl('https://api.openai.com/v1/chat/completions'), + 'https://api.openai.com/v1/responses', + ) + assert.equal( + deriveResponsesUrlFromChatUrl('https://api.openai.com/v1/chat/completions/'), + 'https://api.openai.com/v1/responses', + ) + assert.equal( + deriveResponsesUrlFromChatUrl('https://api.openai.com/v1/responses'), + 'https://api.openai.com/v1/responses', + ) + assert.equal(deriveResponsesUrlFromChatUrl(''), '') +}) + +test('resolveApiProtocolForSession prefers session override then provider setting', () => { + assert.equal(resolveApiProtocolForSession({}, { apiProtocol: 'responses' }), 'responses') + assert.equal(resolveApiProtocolForSession({}, {}), 'chat') + assert.equal( + resolveApiProtocolForSession({ apiMode: { apiProtocol: 'responses' } }, {}), + 'responses', + ) +}) + +test('protocol URL conversion preserves queries and fragments while rewriting only the path', () => { + const suffix = '?api-version=2025-01-01&next=%2Fchat%2Fcompletions&value=a?b/#section' + for (const trailingSlash of ['', '/']) { + assert.equal( + deriveResponsesUrlFromChatUrl( + `https://proxy.example/v1/chat/completions${trailingSlash}${suffix}`, + ), + `https://proxy.example/v1/responses${suffix}`, + ) + assert.equal( + deriveResponsesUrlFromChatUrl(`https://proxy.example/v1/responses${trailingSlash}${suffix}`), + `https://proxy.example/v1/responses${suffix}`, + ) + assert.equal( + deriveChatCompletionsUrlFromResponsesUrl( + `https://proxy.example/v1/responses${trailingSlash}${suffix}`, + ), + `https://proxy.example/v1/chat/completions${suffix}`, + ) + } + assert.equal( + deriveResponsesUrlFromChatUrl(`https://proxy.example/v1${suffix}`), + `https://proxy.example/v1/responses${suffix}`, + ) +}) + +for (const { + chatEndpoint, + responsesUrl, + expectedResponsesUrl = responsesUrl, + expectedChatUrl, + tokenParameter = 'max_tokens', +} of [ + { + chatEndpoint: { chatCompletionsUrl: 'https://chat.example/custom/chat?api-version=1' }, + responsesUrl: 'https://responses.example/custom/respond?api-version=2', + expectedChatUrl: 'https://chat.example/custom/chat?api-version=1', + }, + { + chatEndpoint: { + baseUrl: 'https://chat.example', + chatCompletionsPath: '/custom/chat?api-version=1', + }, + responsesUrl: 'https://responses.example/custom/respond?api-version=2', + expectedChatUrl: 'https://chat.example/custom/chat?api-version=1', + }, + { + chatEndpoint: { chatCompletionsUrl: 'https://chat.example/custom/chat/?api-version=1' }, + responsesUrl: 'https://responses.example/custom/respond?api-version=2', + expectedChatUrl: 'https://chat.example/custom/chat/?api-version=1', + }, + { + chatEndpoint: { chatCompletionsUrl: 'https://chat.example/v1/responses/?api-version=1' }, + expectedResponsesUrl: 'https://chat.example/v1/responses?api-version=1', + expectedChatUrl: 'https://chat.example/v1/responses/?api-version=1', + }, + { + chatEndpoint: { + sourceProviderId: 'openai', + chatCompletionsUrl: 'https://api.openai.com/v1/chat/completions', + }, + responsesUrl: 'https://responses.example/v1/responses', + expectedChatUrl: 'https://api.openai.com/v1/chat/completions', + tokenParameter: 'max_completion_tokens', + }, + { + chatEndpoint: { + sourceProviderId: 'openai', + chatCompletionsUrl: 'https://chat.example/v1/chat/completions', + }, + responsesUrl: 'https://api.openai.com/v1/responses', + expectedChatUrl: 'https://chat.example/v1/chat/completions', + }, +]) { + test(`Responses fallback uses configured Chat endpoint: ${JSON.stringify( + chatEndpoint, + )}`, async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + setStorage({ maxResponseTokenLength: 256 }) + const config = { + customOpenAIProviders: [ + { + id: 'separate-endpoints', + name: 'Separate endpoints', + apiProtocol: 'responses', + responsesUrl, + ...chatEndpoint, + }, + ], + providerSecrets: { 'separate-endpoints': 'key-test' }, + } + const session = { + modelName: 'customModel', + conversationRecords: [], + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'separate-endpoints', + customName: 'gpt-5', + }, + } + const requestedUrls = [] + const requestedBodies = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requestedUrls.push(url) + requestedBodies.push(JSON.parse(init.body)) + if (requestedUrls.length === 1) { + return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }) + } + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + await generateAnswersWithOpenAICompatibleApi(createFakePort(), 'Question', session, config) + assert.deepEqual(requestedUrls, [expectedResponsesUrl, expectedChatUrl]) + assert.equal(requestedBodies[1][tokenParameter], 256) + const otherTokenParameter = + tokenParameter === 'max_tokens' ? 'max_completion_tokens' : 'max_tokens' + assert.equal(Object.hasOwn(requestedBodies[1], otherTokenParameter), false) + assert.deepEqual(session.conversationRecords, [{ question: 'Question', answer: 'Fallback' }]) + }) +} + +test('resolveOpenAICompatibleRequest derives responses URL for responses providers', () => { + const config = { + customOpenAIProviders: [ + { + id: 'responses-proxy', + name: 'Responses Proxy', + baseUrl: 'https://proxy.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + apiProtocol: 'responses', + enabled: true, + }, + ], + providerSecrets: { 'responses-proxy': 'key-1' }, + } + const session = { + modelName: 'customModel', + conversationRecords: [], + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'responses-proxy', + customName: 'gpt-5.6', + customUrl: '', + apiKey: '', + active: true, + }, + } + const request = resolveOpenAICompatibleRequest(config, session) + assert.equal(request.apiProtocol, 'responses') + assert.equal(request.requestUrl, 'https://proxy.example.com/v1/responses') +}) + +test('resolveOpenAICompatibleRequest keeps chat URL by default', () => { + const config = { + customOpenAIProviders: [ + { + id: 'chat-proxy', + name: 'Chat Proxy', + baseUrl: 'https://proxy.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + enabled: true, + }, + ], + providerSecrets: { 'chat-proxy': 'key-1' }, + } + const session = { + modelName: 'customModel', + conversationRecords: [], + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'chat-proxy', + customName: 'gpt-5.6', + customUrl: '', + apiKey: '', + active: true, + }, + } + const request = resolveOpenAICompatibleRequest(config, session) + assert.equal(request.apiProtocol, 'chat') + assert.equal(request.requestUrl, 'https://proxy.example.com/v1/chat/completions') +}) + +test('generateAnswersWithOpenAICompatibleApi routes responses providers to responses endpoint', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 111 }) + const config = { + customOpenAIProviders: [ + { + id: 'responses-proxy', + name: 'Responses Proxy', + baseUrl: 'https://proxy.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + apiProtocol: 'responses', + enabled: true, + }, + ], + providerSecrets: { 'responses-proxy': 'key-1' }, + } + const session = { + modelName: 'customModel', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'responses-proxy', + customName: 'gpt-5.6', + customUrl: '', + apiKey: '', + active: true, + }, + } + const port = createFakePort() + let capturedInput + let capturedInit + t.mock.method(globalThis, 'fetch', async (input, init) => { + capturedInput = input + capturedInit = init + return createMockSseResponse([ + 'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n', + 'data: {"type":"response.completed"}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatibleApi(port, 'CurrentQ', session, config) + + assert.equal(capturedInput, 'https://proxy.example.com/v1/responses') + const body = JSON.parse(capturedInit.body) + assert.equal(body.max_output_tokens, 111) + assert.equal(Object.hasOwn(body, 'messages'), false) + assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hi' }) +}) + +test('generateAnswersWithOpenAICompatibleApi falls back to chat when responses is unsupported', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 222 }) + const config = { + customOpenAIProviders: [ + { + id: 'responses-proxy', + name: 'Responses Proxy', + baseUrl: 'https://proxy.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + apiProtocol: 'responses', + enabled: true, + }, + ], + providerSecrets: { 'responses-proxy': 'key-1' }, + } + const session = { + modelName: 'customModel', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'responses-proxy', + customName: 'gpt-5.6', + customUrl: '', + apiKey: '', + active: true, + }, + } + const port = createFakePort() + const requestedUrls = [] + t.mock.method(globalThis, 'fetch', async (input) => { + requestedUrls.push(String(input)) + if (String(input).endsWith('/v1/responses')) { + return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }) + } + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatibleApi(port, 'CurrentQ', session, config) + + assert.deepEqual(requestedUrls, [ + 'https://proxy.example.com/v1/responses', + 'https://proxy.example.com/v1/chat/completions', + ]) + assert.deepEqual(session.conversationRecords.at(-1), { + question: 'CurrentQ', + answer: 'Fallback', + }) +}) + +test('generateAnswersWithOpenAICompatibleApi honors the OpenAI responses toggle', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 333, + customOpenAiApiUrl: 'https://api.openai.com', + openaiApiProtocol: 'responses', + providerSecrets: { openai: 'sk-test' }, + }) + const session = { + modelName: 'chatgptApi5_6', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'chatgptApiModelKeys', + itemName: 'chatgptApi5_6', + isCustom: false, + }, + } + const port = createFakePort() + let capturedInput + t.mock.method(globalThis, 'fetch', async (input) => { + capturedInput = input + return createMockSseResponse([ + 'data: {"type":"response.completed","response":{"output_text":"Answer"}}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatibleApi(port, 'CurrentQ', session, { + maxConversationContextLength: 3, + maxResponseTokenLength: 333, + customOpenAiApiUrl: 'https://api.openai.com', + openaiApiProtocol: 'responses', + providerSecrets: { openai: 'sk-test' }, + }) + + assert.equal(capturedInput, 'https://api.openai.com/v1/responses') +}) + +test('generateAnswersWithOpenAICompatibleApi keeps legacy completions on chat endpoint', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 333, + customOpenAiApiUrl: 'https://api.openai.com', + openaiApiProtocol: 'responses', + providerSecrets: { openai: 'sk-test' }, + }) + const session = { + modelName: 'gptApiInstruct', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInput + t.mock.method(globalThis, 'fetch', async (input) => { + capturedInput = input + return createMockSseResponse(['data: {"choices":[{"text":"done","finish_reason":"stop"}]}\n\n']) + }) + + await generateAnswersWithOpenAICompatibleApi(port, 'CurrentQ', session, { + maxConversationContextLength: 3, + maxResponseTokenLength: 333, + customOpenAiApiUrl: 'https://api.openai.com', + openaiApiProtocol: 'responses', + providerSecrets: { openai: 'sk-test' }, + }) + + assert.equal(capturedInput, 'https://api.openai.com/v1/completions') +}) + +test('generateAnswersWithOpenAICompatibleApi does not fall back after mid-stream errors', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 222 }) + const config = { + customOpenAIProviders: [ + { + id: 'responses-proxy', + name: 'Responses Proxy', + baseUrl: 'https://proxy.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + apiProtocol: 'responses', + enabled: true, + }, + ], + providerSecrets: { 'responses-proxy': 'key-1' }, + } + const session = { + modelName: 'customModel', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'responses-proxy', + customName: 'gpt-5.6', + customUrl: '', + apiKey: '', + active: true, + }, + } + const port = createFakePort() + const requestedUrls = [] + t.mock.method(globalThis, 'fetch', async (input) => { + requestedUrls.push(String(input)) + return createMockSseResponse([ + 'data: {"type":"response.output_text.delta","delta":"Partial"}\n\n', + 'data: {"type":"error","message":"boom","error":{"message":"boom"}}\n\n', + ]) + }) + + await assert.rejects( + generateAnswersWithOpenAICompatibleApi(port, 'CurrentQ', session, config), + /boom/, + ) + assert.deepEqual(requestedUrls, ['https://proxy.example.com/v1/responses']) +}) + +for (const chatPath of ['/api/chat', '/v1/chat/completions']) { + test(`Responses fallback preserves Ollama behavior for ${chatPath}`, async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + const config = { + customOpenAIProviders: [ + { + id: 'ollama-proxy', + sourceProviderId: 'ollama', + apiProtocol: 'responses', + responsesUrl: 'https://responses.example/v1/responses', + chatCompletionsUrl: `https://ollama.example${chatPath}`, + }, + ], + providerSecrets: { 'ollama-proxy': 'ollama-test-key' }, + ollamaKeepAliveTime: '-1', + } + const session = { + modelName: 'customModel', + conversationRecords: [], + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'ollama-proxy', + customName: 'llama-test', + }, + } + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url, body: JSON.parse(init.body) }) + if (requests.length === 1) return new Response('{}', { status: 404 }) + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + const result = generateAnswersWithOpenAICompatibleApi( + createFakePort(), + 'Question', + session, + config, + ) + if (chatPath === '/api/chat') { + await assert.rejects(result, /Unsupported native Ollama chat endpoint/) + assert.equal(requests.length, 1) + assert.deepEqual(session.conversationRecords, []) + } else { + await result + assert.deepEqual( + requests.map(({ url }) => url), + [ + 'https://responses.example/v1/responses', + 'https://ollama.example/v1/chat/completions', + 'https://ollama.example/api/generate', + ], + ) + assert.equal(requests[2].body.keep_alive, -1) + assert.deepEqual(session.conversationRecords, [{ question: 'Question', answer: 'Fallback' }]) + } + }) +} diff --git a/tests/unit/services/apis/openai-responses-review-regressions.test.mjs b/tests/unit/services/apis/openai-responses-review-regressions.test.mjs new file mode 100644 index 000000000..a6d073f3c --- /dev/null +++ b/tests/unit/services/apis/openai-responses-review-regressions.test.mjs @@ -0,0 +1,413 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { getUserConfig } from '../../../../src/config/index.mjs' +import { initSession } from '../../../../src/services/init-session.mjs' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' +import { generateAnswersWithOpenAIResponses } from '../../../../src/services/apis/openai-responses-core.mjs' +import { resolveOpenAICompatibleRequest } from '../../../../src/services/apis/provider-registry.mjs' +import { buildSelectedModeProviderSecretOverrideUpdate } from '../../../../src/popup/sections/provider-secret-utils.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const chatUrl = 'https://chat.example/v1/chat/completions' +const responsesUrl = 'https://responses.example/custom/respond' + +beforeEach((t) => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) +}) + +async function fixture({ providerProtocol, sessionProtocol, globalProtocol = 'chat' } = {}) { + const apiMode = { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'review-proxy', + customName: 'gpt-4o', + active: true, + ...(sessionProtocol ? { apiProtocol: sessionProtocol } : {}), + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + openaiApiProtocol: globalProtocol, + apiMode, + customApiModes: [apiMode], + customOpenAIProviders: [ + { + id: 'review-proxy', + name: 'Review proxy', + sourceProviderId: 'openai', + chatCompletionsUrl: chatUrl, + responsesUrl, + ...(providerProtocol ? { apiProtocol: providerProtocol } : {}), + }, + ], + providerSecrets: { 'review-proxy': 'test-key' }, + }) + const config = await getUserConfig() + const session = initSession({ modelName: 'customModel', apiMode: config.apiMode }) + return { config, session, port: createFakePort() } +} + +for (const [name, settings, expectedUrl] of [ + ['global Responses respects explicit endpoint', { globalProtocol: 'responses' }, responsesUrl], + [ + 'session Chat overrides provider Responses', + { sessionProtocol: 'chat', providerProtocol: 'responses' }, + chatUrl, + ], + [ + 'session Chat overrides global Responses', + { sessionProtocol: ' ChAt ', globalProtocol: 'responses' }, + chatUrl, + ], + [ + 'session Chat overrides both Responses settings', + { sessionProtocol: 'chat', providerProtocol: 'responses', globalProtocol: 'responses' }, + chatUrl, + ], + ['session Responses overrides provider Chat', { sessionProtocol: ' ReSpOnSeS ' }, responsesUrl], +]) { + test(name, async (t) => { + const { config, session, port } = await fixture(settings) + const calls = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, body: JSON.parse(init.body), headers: init.headers }) + return createMockSseResponse([ + expectedUrl === chatUrl + ? 'data: {"choices":[{"delta":{"content":"Answer"},"finish_reason":"stop"}]}\n\n' + : 'data: {"type":"response.completed","response":{"output_text":"Answer"}}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) + + assert.deepEqual( + calls.map(({ url }) => url), + [expectedUrl], + ) + assert.equal(calls[0].headers.Authorization, 'Bearer test-key') + assert.equal(Object.hasOwn(calls[0].body, 'messages'), expectedUrl === chatUrl) + assert.equal(Object.hasOwn(calls[0].body, 'input'), expectedUrl === responsesUrl) + if (settings.sessionProtocol) { + assert.equal( + resolveOpenAICompatibleRequest(config, session).apiProtocol, + settings.sessionProtocol.trim().toLowerCase(), + ) + } + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'Answer' }]) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const [name, chunks, error] of [ + [ + 'EOF after partial text', + ['data: {"type":"response.output_text.delta","delta":"Partial"}\n\n'], + /ended before completion/i, + ], + ['EOF after creation', ['data: {"type":"response.created"}\n\n'], /ended before completion/i], + ['HTML body', ['upstream unavailable'], /ended before completion/i], + [ + 'JSON error object', + ['{"error":{"message":"Insufficient quota","code":"insufficient_quota"}}'], + /Insufficient quota/, + ], + ['JSON error string', ['{"error":"Upstream failed"}'], /Upstream failed/], + ['JSON error code', ['{"error":{"code":"server_error"}}'], /Responses API request failed/], + ...['queued', 'in_progress', 'cancelled'].map((status) => [ + `JSON ${status} status`, + [JSON.stringify({ object: 'response', status, error: null, output: [] })], + new RegExp(status), + ]), +]) { + test(`${name} preserves the original retry answer and does not finish`, async (t) => { + const port = createFakePort() + const records = [{ question: 'Q', answer: 'Original answer' }] + const session = { conversationRecords: structuredClone(records), isRetry: true } + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(chunks)) + + await assert.rejects( + generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: responsesUrl, + model: 'gpt-4o', + config: {}, + }), + error, + ) + + assert.deepEqual(session.conversationRecords, records) + assert.equal( + port.postedMessages.some((message) => message.done || message.session), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const ending of ['', 'data: {"error":{"message":"Unknown URL /v1/responses"}}\n\n']) { + test(`mid-stream failure does not trigger Chat fallback: ${JSON.stringify( + ending, + )}`, async (t) => { + const { config, session, port } = await fixture({ providerProtocol: 'responses' }) + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"type":"response.output_text.delta","delta":"Partial"}\n\n', + ending, + ]), + ) + + await assert.rejects( + generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config), + /ended before completion|Unknown URL/i, + ) + + assert.equal(fetchMock.mock.callCount(), 1) + assert.deepEqual(session.conversationRecords, []) + assert.equal( + port.postedMessages.some((message) => message.done), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const ending of [ + 'data: {"type":"response.completed"}\n\n', + 'data: {"type":"response.incomplete"}\n\n', + 'data: [DONE]\n\n', +]) { + test(`terminal event still saves partial output once: ${ending.trim()}`, async (t) => { + const port = createFakePort() + const session = { conversationRecords: [] } + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"type":"response.output_text.delta","delta":"Answer"}\n\n', + ending, + ]), + ) + await generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: responsesUrl, + model: 'gpt-4o', + config: {}, + }) + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'Answer' }]) + assert.equal(port.postedMessages.filter((message) => message.done).length, 1) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const status of ['completed', 'incomplete']) { + test(`split JSON ${status} response still saves via the synthetic DONE marker`, async (t) => { + const port = createFakePort() + const session = { conversationRecords: [] } + const json = JSON.stringify({ status, error: null, output_text: 'Answer' }) + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([json.slice(0, 15), json.slice(15)]), + ) + await generateAnswersWithOpenAIResponses({ + port, + question: 'Q', + session, + requestUrl: responsesUrl, + model: 'gpt-4o', + config: {}, + }) + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'Answer' }]) + assert.equal(port.postedMessages.filter((message) => message.done).length, 1) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +test('explicit protocol normalization distinguishes inheritance from Chat', async () => { + const { normalizeExplicitApiProtocol } = await import( + '../../../../src/services/apis/provider-registry.mjs' + ) + for (const value of [undefined, null, '', 'default', 'other']) { + assert.equal(normalizeExplicitApiProtocol(value), undefined) + } + assert.equal(normalizeExplicitApiProtocol(' ChAt '), 'chat') + assert.equal(normalizeExplicitApiProtocol(' ReSpOnSeS '), 'responses') +}) + +for (const [providerProtocol, sessionProtocol, expectedUrl] of [ + ['chat', undefined, chatUrl], + ['chat', 'responses', responsesUrl], + [undefined, undefined, responsesUrl], + ['default', undefined, responsesUrl], +]) { + test(`stored provider protocol ${providerProtocol} with session ${sessionProtocol} respects priority`, async (t) => { + const { config, session, port } = await fixture({ + providerProtocol, + sessionProtocol, + globalProtocol: 'responses', + }) + const calls = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, body: JSON.parse(init.body) }) + return createMockSseResponse([ + expectedUrl === chatUrl + ? 'data: {"choices":[{"delta":{"content":"Answer"},"finish_reason":"stop"}]}\n\n' + : 'data: {"type":"response.completed","response":{"output_text":"Answer"}}\n\n', + ]) + }) + await generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) + assert.deepEqual( + calls.map(({ url }) => url), + [expectedUrl], + ) + assert.equal(Object.hasOwn(calls[0].body, 'messages'), expectedUrl === chatUrl) + const storedProvider = globalThis.__TEST_BROWSER_SHIM__.getStorage().customOpenAIProviders[0] + assert.equal(storedProvider.apiProtocol, providerProtocol === 'chat' ? 'chat' : undefined) + assert.equal( + resolveOpenAICompatibleRequest(config, session).provider.apiProtocol, + storedProvider.apiProtocol, + ) + assert.deepEqual((await getUserConfig()).customOpenAIProviders, config.customOpenAIProviders) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const apiProtocol of [undefined, 'chat', 'responses']) { + test(`secret override materialization preserves provider protocol ${apiProtocol}`, async () => { + const { config } = await fixture({ globalProtocol: 'responses' }) + const source = { ...config.customOpenAIProviders[0], ...(apiProtocol ? { apiProtocol } : {}) } + const { configUpdate } = buildSelectedModeProviderSecretOverrideUpdate( + config, + source.id, + 'override-key', + source, + [source], + ) + const materialized = configUpdate.customOpenAIProviders.at(-1) + assert.equal(materialized.apiProtocol, apiProtocol) + assert.equal(Object.hasOwn(materialized, 'apiProtocol'), Boolean(apiProtocol)) + assert.equal(materialized.responsesUrl, responsesUrl) + globalThis.__TEST_BROWSER_SHIM__.setStorage(configUpdate) + const reloaded = await getUserConfig() + const request = resolveOpenAICompatibleRequest( + reloaded, + initSession({ apiMode: reloaded.apiMode }), + ) + assert.equal(request.provider.apiProtocol, apiProtocol) + assert.equal(request.apiKey, 'override-key') + }) +} + +for (const apiProtocol of ['chat', 'responses']) { + test(`/api/chat URL is guarded only for ${apiProtocol} request bodies`, async (t) => { + const { config, session, port } = await fixture({ providerProtocol: apiProtocol }) + const endpoint = 'https://proxy.example/api/chat' + config.customOpenAIProviders[0] = { + ...config.customOpenAIProviders[0], + apiProtocol, + chatCompletionsUrl: endpoint, + responsesUrl: endpoint, + } + const fetchMock = t.mock.method(globalThis, 'fetch', async (_url, init) => { + assert.equal(Object.hasOwn(JSON.parse(init.body), 'input'), true) + return createMockSseResponse([ + 'data: {"type":"response.completed","response":{"output_text":"Answer"}}\n\n', + ]) + }) + const request = generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) + if (apiProtocol === 'chat') { + await assert.rejects(request, /Unsupported native Ollama chat endpoint/) + assert.equal(fetchMock.mock.callCount(), 0) + } else { + await request + assert.equal(fetchMock.mock.callCount(), 1) + assert.equal(fetchMock.mock.calls[0].arguments[0], endpoint) + } + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const path of ['/custom/respond', '/v1/responses']) { + test(`Responses-only ${path} preserves the original error without a configured Chat endpoint`, async (t) => { + const { config, session, port } = await fixture({ providerProtocol: 'responses' }) + const endpoint = `https://proxy.example${path}` + config.customOpenAIProviders[0] = { + id: 'review-proxy', + apiProtocol: 'responses', + responsesUrl: endpoint, + } + const calls = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, body: JSON.parse(init.body) }) + if (calls.length === 1) { + return new Response(JSON.stringify({ error: { message: 'Original route unavailable' } }), { + status: 404, + }) + } + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Fallback"},"finish_reason":"stop"}]}\n\n', + ]) + }) + const request = generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) + await assert.rejects( + request, + (error) => error.status === 404 && /Original route unavailable/.test(error.message), + ) + assert.deepEqual( + calls.map(({ url }) => url), + [endpoint], + ) + assert.deepEqual(session.conversationRecords, []) + assert.equal( + port.postedMessages.some((message) => message.done), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) +} + +for (const transport of ['SSE', 'JSON', 'split JSON']) { + for (const reason of ['max_output_tokens', 'content_filter', undefined]) { + test(`empty incomplete ${transport} response with reason ${reason} preserves retry history without fallback`, async (t) => { + const { config, session, port } = await fixture({ providerProtocol: 'responses' }) + const records = [{ question: 'Q', answer: 'Original answer' }] + session.conversationRecords = structuredClone(records) + session.isRetry = true + const response = { + object: 'response', + status: 'incomplete', + error: null, + output: [{ type: 'reasoning', summary: [] }], + ...(reason ? { incomplete_details: { reason } } : {}), + } + const json = JSON.stringify(response) + const chunks = + transport === 'SSE' + ? [ + `data: ${JSON.stringify({ type: 'response.incomplete', response })}\n\n`, + 'data: [DONE]\n\n', + ] + : transport === 'split JSON' + ? [json.slice(0, 25), json.slice(25)] + : [json] + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse(chunks), + ) + + await assert.rejects(generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config), { + message: `Responses API response incomplete: ${reason || 'no output text'}`, + }) + + assert.equal(fetchMock.mock.callCount(), 1) + assert.deepEqual(session.conversationRecords, records) + assert.equal(session.isRetry, true) + assert.equal( + port.postedMessages.some((message) => message.done || message.session), + false, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) + }) + } +} diff --git a/tests/unit/utils/fetch-sse.test.mjs b/tests/unit/utils/fetch-sse.test.mjs index 0ae5a57a4..b194e4669 100644 --- a/tests/unit/utils/fetch-sse.test.mjs +++ b/tests/unit/utils/fetch-sse.test.mjs @@ -8,6 +8,98 @@ import { } from '../../../src/utils/fetch-sse.mjs' import { createMockSseResponse } from '../helpers/sse-response.mjs' +for (const prefix of ['{"answer":"', ' ']) { + test(`fetchSSE cancels oversized buffered responses (${JSON.stringify(prefix)})`, async (t) => { + const limit = 16 * 1024 * 1024 + const chunks = [new TextEncoder().encode(prefix), new Uint8Array(limit).fill(32)] + const cancel = t.mock.fn(async () => { + throw new Error('Cancellation failed') + }) + const read = t.mock.fn(async () => ({ done: chunks.length === 0, value: chunks.shift() })) + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + body: { getReader: () => ({ read, cancel }) }, + })) + const onError = t.mock.fn() + const onMessage = t.mock.fn() + const onEnd = t.mock.fn() + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage, + onEnd, + onError, + }) + assert.equal(read.mock.callCount(), 2) + assert.equal(cancel.mock.callCount(), 1) + assert.equal(onError.mock.callCount(), 1) + assert.match(onError.mock.calls[0].arguments[0].message, /16 MiB/) + assert.equal(onMessage.mock.callCount(), 0) + assert.equal(onEnd.mock.callCount(), 0) + }) +} + +test('fetchSSE accepts buffered JSON exactly at the byte limit', async (t) => { + const json = `{"answer":"${' '.repeat(16 * 1024 * 1024 - 16)}臺"}` + assert.equal(new TextEncoder().encode(json).byteLength, 16 * 1024 * 1024) + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse([json])) + const messages = [] + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage: (message) => messages.push(message), + onEnd: async () => {}, + onError: async (error) => { + throw error + }, + }) + assert.deepEqual(messages, [json, '[DONE]']) +}) + +test('fetchSSE counts UTF-8 bytes and does not await stalled cancellation', async (t) => { + const bytes = new TextEncoder().encode(`{"answer":"${'臺'.repeat(6 * 1024 * 1024)}"}`) + const cancel = t.mock.fn(() => new Promise(() => {})) + const read = t.mock.fn(async () => ({ done: false, value: bytes })) + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + body: { + getReader: () => ({ read, cancel }), + }, + })) + const onError = t.mock.fn() + const onMessage = t.mock.fn() + const onEnd = t.mock.fn() + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage, + onEnd, + onError, + }) + assert.equal(cancel.mock.callCount(), 1) + assert.equal(read.mock.callCount(), 1) + assert.equal(onError.mock.callCount(), 1) + assert.match(onError.mock.calls[0].arguments[0].message, /16 MiB/) + assert.equal(onMessage.mock.callCount(), 0) + assert.equal(onEnd.mock.callCount(), 0) +}) + +test('fetchSSE does not apply the JSON limit to SSE chunks or cumulative delivery', async (t) => { + const event = `data: ${'x'.repeat(16 * 1024 * 1024)}\n\n` + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse([event, event])) + let count = 0 + await fetchSSE('https://example.com/sse', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage: () => count++, + onEnd: async () => {}, + onError: async (error) => { + throw error + }, + }) + assert.equal(count, 2) +}) + test('fetchSSE streams SSE chunks and calls lifecycle callbacks', async (t) => { t.mock.method(console, 'debug', () => {}) const starts = [] @@ -68,6 +160,214 @@ test('fetchSSE converts a plain JSON first chunk into fake SSE data', async (t) assert.equal(endCount, 1) }) +for (const bufferJsonResponse of [false, true]) { + test(`fetchSSE buffers split JSON only when opted in (${bufferJsonResponse})`, async (t) => { + t.mock.method(console, 'debug', () => {}) + const messages = [] + const onEnd = t.mock.fn() + const onError = t.mock.fn() + const fetchMock = t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse(['{"answer":', '"ok"}']), + ) + + await fetchSSE('https://example.com/json', { + bufferJsonResponse, + onStart: async () => {}, + onMessage: (message) => messages.push(message), + onEnd, + onError, + }) + + assert.deepEqual(messages, bufferJsonResponse ? ['{"answer":"ok"}', '[DONE]'] : []) + assert.equal(onEnd.mock.callCount(), 1) + assert.equal(onError.mock.callCount(), 0) + assert.equal(Object.hasOwn(fetchMock.mock.calls[0].arguments[1], 'bufferJsonResponse'), false) + }) +} + +test('fetchSSE buffers JSON whitespace and split UTF-8 bytes until EOF', async (t) => { + t.mock.method(console, 'debug', () => {}) + const json = JSON.stringify({ answer: '臺灣 🌏' }) + const bytes = new TextEncoder().encode(` \r\n\t${json}`) + const messages = [] + let reachedEOF = false + const onStart = t.mock.fn() + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + body: { + getReader() { + let index = 0 + return { + async read() { + if (index < bytes.length) return { value: bytes.slice(index, ++index), done: false } + reachedEOF = true + return { done: true } + }, + } + }, + }, + })) + + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart, + onMessage(message) { + assert.equal(reachedEOF, true) + messages.push(message) + }, + onEnd: async () => {}, + onError: async (error) => { + throw error + }, + }) + + assert.deepEqual(messages, [json, '[DONE]']) + assert.equal(onStart.mock.callCount(), 1) +}) + +test('fetchSSE inspects each undecided whitespace chunk only once', async (t) => { + const chunks = [...Array(64).fill(' '.repeat(32)), '{"answer":"ok"}'] + const messages = [] + const trimStart = String.prototype.trimStart + let inspectedCharacters = 0 + t.mock.method(String.prototype, 'trimStart', function () { + inspectedCharacters += this.length + return trimStart.call(this) + }) + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(chunks)) + + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage: (message) => messages.push(message), + onEnd: async () => {}, + onError: async (error) => { + throw error + }, + }) + + assert.deepEqual(messages, ['{"answer":"ok"}', '[DONE]']) + assert.equal(inspectedCharacters, chunks.join('').length) +}) + +test('fetchSSE opt-in keeps SSE delivery incremental', async (t) => { + t.mock.method(console, 'debug', () => {}) + const messages = [] + const response = createMockSseResponse(['\n', 'data: {"delta":"A"}\n\n', 'data: [DONE]\n\n']) + const reader = response.body.getReader() + let reads = 0 + t.mock.method(response.body, 'getReader', () => ({ + async read() { + reads += 1 + if (reads === 3) assert.deepEqual(messages, ['{"delta":"A"}']) + return reader.read() + }, + })) + t.mock.method(globalThis, 'fetch', async () => response) + + await fetchSSE('https://example.com/sse', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage: (message) => messages.push(message), + onEnd: async () => {}, + onError: async (error) => { + throw error + }, + }) + + assert.deepEqual(messages, ['{"delta":"A"}', '[DONE]']) +}) + +test('fetchSSE reports truncated buffered JSON without calling onEnd', async (t) => { + t.mock.method(console, 'debug', () => {}) + const onError = t.mock.fn() + const onEnd = t.mock.fn() + const onMessage = t.mock.fn() + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['{"answer":', '"ok"'])) + + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage, + onEnd, + onError, + }) + + assert.equal(onError.mock.callCount(), 1) + assert.ok(onError.mock.calls[0].arguments[0] instanceof SyntaxError) + assert.equal(onMessage.mock.callCount(), 0) + assert.equal(onEnd.mock.callCount(), 0) +}) + +for (const callback of ['onStart', 'onMessage']) { + test(`fetchSSE forwards buffered JSON ${callback} failures once`, async (t) => { + t.mock.method(console, 'debug', () => {}) + const failure = new Error('callback failed') + const onError = t.mock.fn() + const onEnd = t.mock.fn() + const messages = [] + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['{"answer":', '"ok"}'])) + + await assert.rejects( + fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage: (message) => messages.push(message), + onEnd, + onError, + [callback]: async () => { + throw failure + }, + }), + (error) => error === failure, + ) + + assert.equal(onError.mock.callCount(), 1) + assert.equal(onError.mock.calls[0].arguments[0], failure) + assert.equal(onEnd.mock.callCount(), 0) + assert.deepEqual(messages, []) + }) +} + +for (const aborted of [false, true]) { + test(`fetchSSE does not emit buffered JSON after a read failure (aborted: ${aborted})`, async (t) => { + const failure = aborted + ? new DOMException('Request aborted', 'AbortError') + : new TypeError('Connection lost') + const response = createMockSseResponse(['{"answer":"ok"}']) + const reader = response.body.getReader() + let reads = 0 + t.mock.method(response.body, 'getReader', () => ({ + async read() { + if (reads++) throw failure + return reader.read() + }, + })) + t.mock.method(globalThis, 'fetch', async () => response) + const onError = t.mock.fn() + const onEnd = t.mock.fn() + const onMessage = t.mock.fn() + + await fetchSSE('https://example.com/json', { + bufferJsonResponse: true, + onStart: async () => {}, + onMessage, + onEnd, + onError, + }) + + assert.equal(onMessage.mock.callCount(), 0) + assert.equal(onEnd.mock.callCount(), aborted ? 1 : 0) + assert.equal(onError.mock.callCount(), aborted ? 0 : 1) + if (aborted) { + assert.deepEqual(onEnd.mock.calls[0].arguments, [true]) + } else { + assert.equal(onError.mock.calls[0].arguments[0], failure) + assert.equal(failure.code, FETCH_RESPONSE_STREAM_FAILED) + } + }) +} + test('fetchSSE forwards non-ok responses to onError', async (t) => { t.mock.method(console, 'debug', () => {}) const errors = []