diff --git a/.jshintrc b/.jshintrc index 5f3bec3f..ed6e6ef8 100644 --- a/.jshintrc +++ b/.jshintrc @@ -166,6 +166,7 @@ "Event": true, "ace": true, "self": true, - "AbortController": true + "AbortController": true, + "AbortSignal": true } } diff --git a/app/manifest.json b/app/manifest.json index 6009ad38..6963004a 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -28,7 +28,7 @@ } ], "content_security_policy": { - "extension_pages": "default-src 'self'; img-src 'self' data:; style-src 'unsafe-inline';" + "extension_pages": "default-src 'self'; connect-src 'self' http: https:; img-src 'self' data:; style-src 'unsafe-inline';" }, "description": "With the UI5 Inspector, you can easily debug and support your OpenUI5 or SAPUI5-based apps.", "devtools_page": "/html/devtools/index.html", diff --git a/app/scripts/background/main.js b/app/scripts/background/main.js index 4065cc77..2fe92ef0 100644 --- a/app/scripts/background/main.js +++ b/app/scripts/background/main.js @@ -4,6 +4,7 @@ var utils = require('../modules/utils/utils.js'); var ContextMenu = require('../modules/background/ContextMenu.js'); var pageAction = require('../modules/background/pageAction.js'); + var attachOpenAIHandler = require('../modules/background/openaiHandler.js'); var contextMenu = new ContextMenu({ title: 'Inspect UI5 element', @@ -501,6 +502,8 @@ break; } }); + } else if (port.name === 'openai-api') { + attachOpenAIHandler(port); } }); diff --git a/app/scripts/modules/ai/AssistantController.js b/app/scripts/modules/ai/AssistantController.js index b379c4ae..a864eeba 100644 --- a/app/scripts/modules/ai/AssistantController.js +++ b/app/scripts/modules/ai/AssistantController.js @@ -41,6 +41,7 @@ function AssistantController({ this._clearConsoleErrors = clearConsoleErrors; this._capabilityState = { status: 'unavailable', message: 'Checking model status...', progress: 0 }; + this._lastReadyMessage = 'Ready'; this._listeners = {}; this._currentUrl = null; this._conversationMemory = []; @@ -91,6 +92,9 @@ AssistantController.prototype._setCapabilityState = function (status, message, p message: message || '', progress: typeof progress === 'number' ? progress : 0 }; + if (status === 'ready' && message) { + this._lastReadyMessage = message; + } this._emit('capability-state-changed', this._capabilityState); }; @@ -161,7 +165,7 @@ AssistantController.prototype.sendUserMessage = function (userMessage) { this._conversationMemory.push({ role: 'assistant', content: fullText }); this._isStreaming = false; if (this._capabilityState.status === 'streaming-failed') { - this._setCapabilityState('ready', 'Gemini Nano is ready', 0); + this._setCapabilityState('ready', this._lastReadyMessage, 0); } this._emit('stream-complete', { content: fullText }); return { content: fullText }; @@ -203,7 +207,7 @@ AssistantController.prototype.setUrl = function (url) { return this._loadConversationMemory().then(() => { this._provider.destroy(); - this._setCapabilityState('ready', 'Gemini Nano is ready', 0); + this._setCapabilityState('ready', this._lastReadyMessage, 0); }); }; @@ -237,7 +241,7 @@ AssistantController.prototype.clearConversation = function () { this._provider.destroy(); this._emit('conversation-cleared'); if (this._capabilityState.status === 'ready') { - this._setCapabilityState('ready', 'Gemini Nano is ready', 0); + this._setCapabilityState('ready', this._lastReadyMessage, 0); } }); }; diff --git a/app/scripts/modules/ai/OpenAIProvider.js b/app/scripts/modules/ai/OpenAIProvider.js new file mode 100644 index 00000000..35c1239a --- /dev/null +++ b/app/scripts/modules/ai/OpenAIProvider.js @@ -0,0 +1,188 @@ +'use strict'; + +/** + * AI Provider backed by any OpenAI-compatible HTTP endpoint. Wraps the background service worker's + * `openai-api` port protocol — the panel's CSP blocks cross-origin `fetch`, so all network I/O + * runs in the service worker (see modules/background/openaiHandler.js). + * + * @param {Object} config + * @param {string} config.baseUrl - e.g. `http://localhost:6655/openai/v1`. No trailing `/`. + * @param {string} config.apiKey - Bearer token. Never included in error messages or logs. + * @param {string} config.model - Model identifier passed to the API. + * @param {Function} [config.portFactory] - Test seam. Defaults to a `chrome.runtime.connect` call. + * @constructor + */ +function OpenAIProvider(config) { + const cfg = config || {}; + this._baseUrl = cfg.baseUrl || ''; + this._apiKey = cfg.apiKey || ''; + this._model = cfg.model || ''; + this._portFactory = cfg.portFactory || function () { + return chrome.runtime.connect({ name: 'openai-api' }); + }; + this._port = null; + this._isConnected = false; + this._messageHandlers = {}; + this._disconnectHandler = null; +} + +function abortError() { + const err = new Error('Aborted'); + err.name = 'AbortError'; + return err; +} + +OpenAIProvider.prototype._connect = function () { + if (this._isConnected) { + return; + } + + this._port = this._portFactory(); + this._isConnected = true; + + this._port.onMessage.addListener((message) => { + const handler = this._messageHandlers[message.type]; + if (handler) { + handler(message); + } + }); + + this._port.onDisconnect.addListener(() => { + this._isConnected = false; + this._port = null; + + if (this._disconnectHandler) { + const h = this._disconnectHandler; + this._disconnectHandler = null; + h(); + } + }); +}; + +/** + * Return `ready` iff `baseUrl`, `apiKey`, and `model` are all set. Local check — no port traffic. + * @returns {Promise<{status: string, message: string}>} + */ +OpenAIProvider.prototype.checkAvailability = function () { + if (this._baseUrl && this._apiKey && this._model) { + return Promise.resolve({ status: 'ready', message: 'OpenAI-compatible (' + this._model + ') ready' }); + } + return Promise.resolve({ status: 'unavailable', message: 'not configured' }); +}; + +/** + * Post the messages to the background over the `openai-api` port. Route incoming + * `chunk`/`complete`/`error` frames. Resolve with the accumulated text on `complete`. + * + * @param {Array<{role: string, content: string}>} messages + * @param {{onChunk?: Function, signal?: AbortSignal}} [options] + * @returns {Promise} + */ +OpenAIProvider.prototype.sendMessage = function (messages, options) { + const opts = options || {}; + + if (!Array.isArray(messages) || messages.length === 0) { + return Promise.reject(new Error('sendMessage: messages must be a non-empty array')); + } + if (opts.signal && opts.signal.aborted) { + return Promise.reject(abortError()); + } + + return new Promise((resolve, reject) => { + let settled = false; + let fullText = ''; + let abortListener = null; + + const cleanup = () => { + delete this._messageHandlers.chunk; + delete this._messageHandlers.complete; + delete this._messageHandlers.error; + this._disconnectHandler = null; + if (opts.signal && abortListener) { + opts.signal.removeEventListener('abort', abortListener); + } + }; + + const settle = (fn) => { + if (settled) { + return; + } + settled = true; + cleanup(); + fn(); + }; + + this._messageHandlers.chunk = (message) => { + if (settled) { + return; + } + fullText += message.content; + if (typeof opts.onChunk === 'function') { + opts.onChunk(message.content); + } + }; + + this._messageHandlers.complete = () => { + settle(() => resolve(fullText)); + }; + + this._messageHandlers.error = (message) => { + settle(() => reject(new Error(message.message))); + }; + + this._disconnectHandler = () => { + settle(() => reject(new Error('Connection to background script lost. Please try again.'))); + }; + + if (opts.signal) { + abortListener = () => { + if (this._isConnected) { + this._port.postMessage({ type: 'cancel' }); + } + settle(() => reject(abortError())); + }; + opts.signal.addEventListener('abort', abortListener); + } + + this._connect(); + this._port.postMessage({ + type: 'send', + config: { + baseUrl: this._baseUrl, + apiKey: this._apiKey, + model: this._model + }, + messages: messages + }); + }); +}; + +/** + * OpenAI-compatible endpoints expose no client-visible token quota, so there is no usage to + * report. Returning `null` tells the UI to leave the token pill untouched (see AIChat). + * @returns {Promise} + */ +OpenAIProvider.prototype.getUsageInfo = function () { + return Promise.resolve(null); +}; + +/** + * Cancel any in-flight request and disconnect the port. + */ +OpenAIProvider.prototype.destroy = function () { + if (this._disconnectHandler) { + const h = this._disconnectHandler; + this._disconnectHandler = null; + h(); + } + if (this._isConnected && this._port) { + this._port.postMessage({ type: 'cancel' }); + this._port.disconnect(); + } + this._port = null; + this._isConnected = false; + this._messageHandlers = {}; + this._disconnectHandler = null; +}; + +module.exports = OpenAIProvider; diff --git a/app/scripts/modules/ai/providers/index.js b/app/scripts/modules/ai/providers/index.js index 50277ed0..5444fa0d 100644 --- a/app/scripts/modules/ai/providers/index.js +++ b/app/scripts/modules/ai/providers/index.js @@ -1,6 +1,7 @@ 'use strict'; const GeminiNanoProvider = require('../GeminiNanoProvider.js'); +const OpenAIProvider = require('../OpenAIProvider.js'); /** * Static registry of AI providers available to the assistant. Keys are provider names persisted in @@ -16,6 +17,15 @@ const PROVIDERS = { displayName: 'Gemini Nano (on-device)', ProviderClass: GeminiNanoProvider, configSchema: [] + }, + 'openai': { + displayName: 'OpenAI-compatible', + ProviderClass: OpenAIProvider, + configSchema: [ + { key: 'baseUrl', label: 'Base URL', type: 'text', required: true }, + { key: 'apiKey', label: 'API Key', type: 'password', required: true }, + { key: 'model', label: 'Model', type: 'text', required: true } + ] } }; diff --git a/app/scripts/modules/background/openaiHandler.js b/app/scripts/modules/background/openaiHandler.js new file mode 100644 index 00000000..a1f5522f --- /dev/null +++ b/app/scripts/modules/background/openaiHandler.js @@ -0,0 +1,179 @@ +'use strict'; + +const DONE = 'sse:done'; + +function redact(text, apiKey) { + if (!apiKey || typeof text !== 'string') { + return text; + } + return text.split(apiKey).join('[redacted]'); +} + +function parseSseEvent(event) { + const trimmed = event.trim(); + if (!trimmed.startsWith('data:')) { + return ''; + } + const payload = trimmed.slice(5).trim(); + if (payload === '[DONE]') { + return DONE; + } + try { + const parsed = JSON.parse(payload); + const choices = parsed && parsed.choices; + if (!choices || !choices.length) { + return ''; + } + const delta = choices[0].delta; + return (delta && typeof delta.content === 'string') ? delta.content : ''; + } catch (e) { + return ''; + } +} + +function extractErrorMessage(response, apiKey) { + return response.json().then( + function (body) { + if (body && body.error && body.error.message) { + return redact(body.error.message, apiKey); + } + return 'HTTP ' + response.status; + }, + function () { + return 'HTTP ' + response.status; + } + ); +} + +function readSseStream(body, port, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + function pump() { + if (signal.aborted) { + reader.cancel(); + return; + } + return reader.read().then(function (result) { + if (signal.aborted) { + return; + } + if (result.done) { + port.postMessage({ type: 'complete' }); + return; + } + buffer += decoder.decode(result.value, { stream: true }); + + let separatorIdx = buffer.indexOf('\n\n'); + while (separatorIdx !== -1) { + const event = buffer.slice(0, separatorIdx); + buffer = buffer.slice(separatorIdx + 2); + + const delta = parseSseEvent(event); + if (delta === DONE) { + reader.cancel(); + port.postMessage({ type: 'complete' }); + return; + } + if (delta) { + port.postMessage({ type: 'chunk', content: delta }); + } + separatorIdx = buffer.indexOf('\n\n'); + } + return pump(); + }, function (err) { + if (signal.aborted) { + return; + } + port.postMessage({ type: 'error', message: err && err.message ? err.message : 'Stream error' }); + }); + } + + return pump(); +} + +/** + * Background-side handler for the `openai-api` port. Handles one port instance: on `send`, kicks + * off a `fetch` to `${baseUrl}/chat/completions`, parses the SSE response, and streams + * `chunk`/`complete`/`error` messages back over the port. On `cancel` (or port disconnect), aborts + * the in-flight fetch. + * + * The panel-side `OpenAIProvider` runs under a strict CSP (`default-src 'self'`) that blocks + * cross-origin fetch. The background service worker has broad `host_permissions` and no such CSP, + * so the network I/O lives here. + * + * @param {chrome.runtime.Port} port + * @param {{fetch?: Function}} [options] - Test seam for `fetch`. Defaults to the global `fetch`. + */ +function attachOpenAIHandler(port, options) { + const opts = options || {}; + const fetchImpl = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null); + + let controller = null; + + function handleSend(message) { + if (controller) { + controller.abort(); + } + controller = new AbortController(); + const signal = controller.signal; + + const config = message.config || {}; + const url = config.baseUrl + '/chat/completions'; + const body = JSON.stringify({ + model: config.model, + messages: message.messages, + stream: true + }); + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + config.apiKey + }; + + fetchImpl(url, { method: 'POST', headers: headers, body: body, signal: signal }).then( + function (response) { + if (signal.aborted) { + return; + } + if (!response.ok) { + return extractErrorMessage(response, config.apiKey).then(function (msg) { + if (!signal.aborted) { + port.postMessage({ type: 'error', message: msg }); + } + }); + } + return readSseStream(response.body, port, signal); + }, + function (err) { + if (signal.aborted) { + return; + } + const raw = err && err.message ? err.message : 'Network error'; + port.postMessage({ type: 'error', message: redact(raw, config.apiKey) }); + } + ); + } + + port.onMessage.addListener(function (message) { + if (message.type === 'send') { + handleSend(message); + } else if (message.type === 'cancel') { + if (controller) { + controller.abort(); + controller = null; + } + } + }); + + port.onDisconnect.addListener(function () { + if (controller) { + controller.abort(); + controller = null; + } + }); +} + +module.exports = attachOpenAIHandler; +module.exports.parseSseEvent = parseSseEvent; +module.exports.DONE = DONE; diff --git a/tests/modules/ai/AssistantController.spec.js b/tests/modules/ai/AssistantController.spec.js index 4c20b545..82e8af95 100644 --- a/tests/modules/ai/AssistantController.spec.js +++ b/tests/modules/ai/AssistantController.spec.js @@ -1040,6 +1040,35 @@ describe('AssistantController', function () { }); }); }); + + it('should re-emit the provider\'s own ready message (not a hard-coded string) after setUrl, so the banner reflects the active provider — a non-Gemini provider is not mislabelled as Gemini', function () { + const harness = createController(); + harness.provider.availabilityResult = { status: 'ready', message: 'OpenAI-compatible (gpt-4o-mini) is ready' }; + harness.controller.setUrl('https://example.com'); + return harness.controller.initialize().then(function () { + const stateCountBeforeSwitch = harness.capabilityStates.length; + return harness.controller.setUrl('https://other.example.com').then(function () { + const newStates = harness.capabilityStates.slice(stateCountBeforeSwitch); + newStates.should.have.length(1); + newStates[0].status.should.equal('ready'); + newStates[0].message.should.equal('OpenAI-compatible (gpt-4o-mini) is ready'); + }); + }); + }); + + it('should re-emit the provider\'s own ready message (not a hard-coded string) after clearConversation, so the banner is not clobbered with a Gemini-specific label on a different provider', function () { + const harness = createController(); + harness.provider.availabilityResult = { status: 'ready', message: 'OpenAI-compatible ready' }; + harness.controller.setUrl('https://example.com'); + return harness.controller.initialize().then(function () { + const stateCountBeforeClear = harness.capabilityStates.length; + return harness.controller.clearConversation().then(function () { + const newStates = harness.capabilityStates.slice(stateCountBeforeClear); + newStates.should.have.length(1); + newStates[0].message.should.equal('OpenAI-compatible ready'); + }); + }); + }); }); describe('idle-killed session recovery (provider-internal)', function () { diff --git a/tests/modules/ai/OpenAIProvider.spec.js b/tests/modules/ai/OpenAIProvider.spec.js new file mode 100644 index 00000000..eabd5bc0 --- /dev/null +++ b/tests/modules/ai/OpenAIProvider.spec.js @@ -0,0 +1,317 @@ +'use strict'; + +const OpenAIProvider = require('../../../app/scripts/modules/ai/OpenAIProvider.js'); + +function createFakePort() { + const messageListeners = []; + const disconnectListeners = []; + + const port = { + postMessage: function (message) { + port.posted.push(message); + }, + onMessage: { + addListener: function (listener) { + messageListeners.push(listener); + } + }, + onDisconnect: { + addListener: function (listener) { + disconnectListeners.push(listener); + } + }, + disconnect: function () { + port.disconnected = true; + }, + posted: [], + disconnected: false + }; + + return { + port: port, + posted: port.posted, + emit: function (message) { + messageListeners.forEach(function (listener) { + listener(message); + }); + }, + triggerDisconnect: function () { + disconnectListeners.forEach(function (listener) { + listener(); + }); + } + }; +} + +function defaultConfig(overrides) { + return Object.assign({ + baseUrl: 'http://localhost:6655/openai/v1', + apiKey: 'secret-key', + model: 'gpt-5.4' + }, overrides || {}); +} + +function createProvider(configOverrides) { + const fake = createFakePort(); + const provider = new OpenAIProvider(Object.assign(defaultConfig(configOverrides), { + portFactory: function () { return fake.port; } + })); + return { provider: provider, fake: fake }; +} + +describe('OpenAIProvider', function () { + describe('#checkAvailability()', function () { + it('should return `ready` when baseUrl, apiKey, and model are all present', function () { + const { provider } = createProvider(); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('ready'); + }); + }); + + it('should identify itself in the ready message so the banner does not fall back to a generic or Gemini-shaped label after setUrl / clearConversation re-emits it', function () { + const { provider } = createProvider({ model: 'gpt-4o-mini' }); + return provider.checkAvailability().then(function (result) { + result.message.should.contain('OpenAI'); + result.message.should.contain('gpt-4o-mini'); + }); + }); + + it('should return `unavailable` when baseUrl is missing', function () { + const { provider } = createProvider({ baseUrl: '' }); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('unavailable'); + result.message.should.contain('not configured'); + }); + }); + + it('should return `unavailable` when apiKey is missing', function () { + const { provider } = createProvider({ apiKey: '' }); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('unavailable'); + }); + }); + + it('should return `unavailable` when model is missing', function () { + const { provider } = createProvider({ model: '' }); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('unavailable'); + }); + }); + + it('should not post anything on the port', function () { + const { provider, fake } = createProvider(); + return provider.checkAvailability().then(function () { + fake.posted.should.have.length(0); + }); + }); + }); + + describe('#sendMessage() — port protocol', function () { + it('should reject when the messages array is empty', function () { + const { provider } = createProvider(); + return provider.sendMessage([]).then(function () { + throw new Error('Expected sendMessage to reject'); + }, function (err) { + err.message.should.contain('non-empty'); + }); + }); + + it('should reject with AbortError when the signal is already aborted', function () { + const { provider } = createProvider(); + const controller = new AbortController(); + controller.abort(); + return provider.sendMessage([ + { role: 'user', content: 'Hi' } + ], { signal: controller.signal }).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.name.should.equal('AbortError'); + }); + }); + + it('should post {type:send, config, messages} on the port carrying baseUrl, apiKey, and model', async function () { + const { provider, fake } = createProvider(); + const messages = [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Hello' } + ]; + + const sendPromise = provider.sendMessage(messages); + await Promise.resolve(); + + fake.posted.should.have.length(1); + const posted = fake.posted[0]; + posted.type.should.equal('send'); + posted.messages.should.deep.equal(messages); + posted.config.should.deep.equal({ + baseUrl: 'http://localhost:6655/openai/v1', + apiKey: 'secret-key', + model: 'gpt-5.4' + }); + + fake.emit({ type: 'chunk', content: 'x' }); + fake.emit({ type: 'complete' }); + await sendPromise; + }); + + it('should forward chunk messages via onChunk and resolve with the accumulated text on complete', async function () { + const { provider, fake } = createProvider(); + const received = []; + const sendPromise = provider.sendMessage( + [{ role: 'user', content: 'Hi' }], + { onChunk: function (t) { received.push(t); } } + ); + + await Promise.resolve(); + fake.emit({ type: 'chunk', content: 'Hello, ' }); + fake.emit({ type: 'chunk', content: 'world' }); + fake.emit({ type: 'chunk', content: '!' }); + fake.emit({ type: 'complete' }); + + const full = await sendPromise; + full.should.equal('Hello, world!'); + received.should.deep.equal(['Hello, ', 'world', '!']); + }); + + it('should reject with the transport-supplied message when an error frame arrives', async function () { + const { provider, fake } = createProvider(); + const sendPromise = provider.sendMessage([{ role: 'user', content: 'Hi' }]); + + await Promise.resolve(); + fake.emit({ type: 'error', message: 'Invalid API key' }); + + try { + await sendPromise; + throw new Error('Expected reject'); + } catch (err) { + err.message.should.equal('Invalid API key'); + } + }); + + it('should reject when the port disconnects mid-request', async function () { + const { provider, fake } = createProvider(); + const sendPromise = provider.sendMessage([{ role: 'user', content: 'Hi' }]); + + await Promise.resolve(); + fake.triggerDisconnect(); + + try { + await sendPromise; + throw new Error('Expected reject'); + } catch (err) { + err.message.should.contain('Connection'); + } + }); + }); + + describe('#sendMessage() — cancellation', function () { + it('should post {type:cancel} and reject with AbortError when the signal aborts mid-stream', async function () { + const { provider, fake } = createProvider(); + const controller = new AbortController(); + + const sendPromise = provider.sendMessage( + [{ role: 'user', content: 'Hi' }], + { signal: controller.signal } + ); + + await Promise.resolve(); + fake.emit({ type: 'chunk', content: 'partial' }); + controller.abort(); + + try { + await sendPromise; + throw new Error('Expected reject'); + } catch (err) { + err.name.should.equal('AbortError'); + } + + const cancelPosts = fake.posted.filter(function (m) { return m.type === 'cancel'; }); + cancelPosts.should.have.length(1); + }); + + it('should ignore chunks that arrive after the signal aborts', async function () { + const { provider, fake } = createProvider(); + const controller = new AbortController(); + const received = []; + + const sendPromise = provider.sendMessage( + [{ role: 'user', content: 'Hi' }], + { + onChunk: function (t) { received.push(t); }, + signal: controller.signal + } + ); + + await Promise.resolve(); + fake.emit({ type: 'chunk', content: 'first' }); + controller.abort(); + + // Late chunk after abort — must not be surfaced. + fake.emit({ type: 'chunk', content: 'late' }); + fake.emit({ type: 'complete' }); + + try { + await sendPromise; + throw new Error('Expected reject'); + } catch (err) { + err.name.should.equal('AbortError'); + } + + received.should.deep.equal(['first']); + }); + }); + + describe('#destroy()', function () { + it('should disconnect the port when a request has been sent', async function () { + const { provider, fake } = createProvider(); + provider.sendMessage([{ role: 'user', content: 'Hi' }]).catch(function () { /* ignore */ }); + + await Promise.resolve(); + provider.destroy(); + + fake.port.disconnected.should.be.true; + }); + + it('should post {type:cancel} when an in-flight request is destroyed', async function () { + const { provider, fake } = createProvider(); + provider.sendMessage([{ role: 'user', content: 'Hi' }]).catch(function () { /* ignore */ }); + + await Promise.resolve(); + provider.destroy(); + + const cancelPosts = fake.posted.filter(function (m) { return m.type === 'cancel'; }); + cancelPosts.should.have.length(1); + }); + + it('should be a no-op when never connected', function () { + const { provider, fake } = createProvider(); + provider.destroy(); + fake.port.disconnected.should.be.false; + fake.posted.should.have.length(0); + }); + + it('should reject an in-flight sendMessage promise rather than leave it pending', async function () { + const { provider } = createProvider(); + const sendPromise = provider.sendMessage([{ role: 'user', content: 'Hi' }]); + + await Promise.resolve(); + provider.destroy(); + + try { + await sendPromise; + throw new Error('Expected reject'); + } catch (err) { + err.message.should.not.equal('Expected reject'); + } + }); + }); + + describe('#getUsageInfo()', function () { + it('should resolve to null (OpenAI-compatible endpoints expose no client-visible quota)', function () { + const { provider } = createProvider(); + return provider.getUsageInfo().then(function (usage) { + (usage === null).should.be.true; + }); + }); + }); +}); diff --git a/tests/modules/background/openaiHandler.spec.js b/tests/modules/background/openaiHandler.spec.js new file mode 100644 index 00000000..07a497bc --- /dev/null +++ b/tests/modules/background/openaiHandler.spec.js @@ -0,0 +1,452 @@ +'use strict'; + +const attachOpenAIHandler = require('../../../app/scripts/modules/background/openaiHandler.js'); +const parseSseEvent = attachOpenAIHandler.parseSseEvent; +const DONE = attachOpenAIHandler.DONE; + +function createFakePort() { + const messageListeners = []; + const disconnectListeners = []; + + const port = { + name: 'openai-api', + postMessage: function (message) { + port.posted.push(message); + }, + onMessage: { + addListener: function (listener) { + messageListeners.push(listener); + } + }, + onDisconnect: { + addListener: function (listener) { + disconnectListeners.push(listener); + } + }, + posted: [] + }; + + return { + port: port, + posted: port.posted, + deliver: function (message) { + messageListeners.forEach(function (l) { l(message); }); + }, + triggerDisconnect: function () { + disconnectListeners.forEach(function (l) { l(); }); + } + }; +} + +function encodeChunks(strings) { + const encoder = new TextEncoder(); + return strings.map(function (s) { return encoder.encode(s); }); +} + +function makeStreamingResponse(chunks, options) { + const opts = options || {}; + let cursor = 0; + let cancelled = false; + const body = { + getReader: function () { + return { + read: function () { + if (opts.signal && opts.signal.aborted) { + const err = new Error('Aborted'); + err.name = 'AbortError'; + return Promise.reject(err); + } + if (cancelled || cursor >= chunks.length) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: chunks[cursor++] }); + }, + cancel: function () { + cancelled = true; + return Promise.resolve(); + } + }; + } + }; + return { + ok: true, + status: 200, + body: body + }; +} + +function sseContent(text) { + return 'data: ' + JSON.stringify({ choices: [{ delta: { content: text } }] }) + '\n\n'; +} + +const validConfig = { + baseUrl: 'http://localhost:6655/openai/v1', + apiKey: 'secret-key', + model: 'gpt-5.4' +}; + +const validMessages = [{ role: 'user', content: 'Hi' }]; + +describe('openaiHandler', function () { + describe('happy path — SSE streaming', function () { + it('should POST to `${baseUrl}/chat/completions` with the messages, model, and stream:true', function () { + const fake = createFakePort(); + const fetchCalls = []; + const fetchImpl = function (url, options) { + fetchCalls.push({ url: url, options: options }); + return Promise.resolve(makeStreamingResponse(encodeChunks([ + sseContent('hi'), + 'data: [DONE]\n\n' + ]))); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 20); }).then(function () { + fetchCalls.should.have.length(1); + fetchCalls[0].url.should.equal('http://localhost:6655/openai/v1/chat/completions'); + fetchCalls[0].options.method.should.equal('POST'); + fetchCalls[0].options.headers.Authorization.should.equal('Bearer secret-key'); + fetchCalls[0].options.headers['Content-Type'].should.equal('application/json'); + const body = JSON.parse(fetchCalls[0].options.body); + body.model.should.equal('gpt-5.4'); + body.stream.should.equal(true); + body.messages.should.deep.equal(validMessages); + }); + }); + + it('should forward each SSE content delta as a `chunk` message then emit `complete` on [DONE]', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeStreamingResponse(encodeChunks([ + sseContent('Hello, '), + sseContent('world'), + sseContent('!'), + 'data: [DONE]\n\n' + ]))); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 30); }).then(function () { + const chunks = fake.posted.filter(function (m) { return m.type === 'chunk'; }); + chunks.map(function (m) { return m.content; }).should.deep.equal(['Hello, ', 'world', '!']); + const completes = fake.posted.filter(function (m) { return m.type === 'complete'; }); + completes.should.have.length(1); + }); + }); + + it('should buffer SSE events split across reads', function () { + const fake = createFakePort(); + const event = sseContent('streamed'); + const midpoint = Math.floor(event.length / 2); + const encoder = new TextEncoder(); + + const fetchImpl = function () { + return Promise.resolve(makeStreamingResponse([ + encoder.encode(event.slice(0, midpoint)), + encoder.encode(event.slice(midpoint)), + encoder.encode('data: [DONE]\n\n') + ])); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 30); }).then(function () { + const chunks = fake.posted.filter(function (m) { return m.type === 'chunk'; }); + chunks.map(function (c) { return c.content; }).join('').should.equal('streamed'); + }); + }); + + it('should ignore SSE events whose delta has no content field (e.g. role-only opener)', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeStreamingResponse(encodeChunks([ + 'data: ' + JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }) + '\n\n', + sseContent('body'), + 'data: [DONE]\n\n' + ]))); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 30); }).then(function () { + const chunks = fake.posted.filter(function (m) { return m.type === 'chunk'; }); + chunks.map(function (c) { return c.content; }).should.deep.equal(['body']); + }); + }); + }); + + describe('parseSseEvent', function () { + it('should return the delta content string for a content event', function () { + parseSseEvent(sseContent('hello')).should.equal('hello'); + }); + + it('should return DONE for the [DONE] sentinel', function () { + parseSseEvent('data: [DONE]').should.equal(DONE); + }); + + it('should return empty string for a role-only opener (delta with no content)', function () { + const event = 'data: ' + JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }); + parseSseEvent(event).should.equal(''); + }); + + it('should return empty string for a comment/keep-alive line (no data: prefix)', function () { + parseSseEvent(': keep-alive').should.equal(''); + }); + + it('should return empty string for a non-JSON data payload', function () { + parseSseEvent('data: not json').should.equal(''); + }); + + it('should return empty string when choices is empty', function () { + parseSseEvent('data: ' + JSON.stringify({ choices: [] })).should.equal(''); + }); + }); + + describe('error surfacing', function () { + function makeErrorResponse(status, errorBody) { + return { + ok: false, + status: status, + json: function () { return Promise.resolve(errorBody); }, + text: function () { return Promise.resolve(JSON.stringify(errorBody)); } + }; + } + + [ + { status: 401, message: 'Invalid API key' }, + { status: 404, message: 'Model not found' }, + { status: 429, message: 'Rate limited' } + ].forEach(function (testCase) { + it('should post {type:error} with the API `error.message` on ' + testCase.status, function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeErrorResponse(testCase.status, { error: { message: testCase.message } })); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 20); }).then(function () { + const errors = fake.posted.filter(function (m) { return m.type === 'error'; }); + errors.should.have.length(1); + errors[0].message.should.equal(testCase.message); + }); + }); + }); + + it('should fall back to `HTTP ${status}` when the API body is not parseable', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve({ + ok: false, + status: 500, + json: function () { return Promise.reject(new Error('bad json')); }, + text: function () { return Promise.resolve(''); } + }); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 20); }).then(function () { + const errors = fake.posted.filter(function (m) { return m.type === 'error'; }); + errors.should.have.length(1); + errors[0].message.should.contain('500'); + }); + }); + + it('should surface fetch rejection as {type:error}', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.reject(new Error('network down')); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 20); }).then(function () { + const errors = fake.posted.filter(function (m) { return m.type === 'error'; }); + errors.should.have.length(1); + errors[0].message.should.contain('network down'); + }); + }); + + it('should never leak the apiKey into any posted message', function () { + const fake = createFakePort(); + const secret = 'super-secret-abc123'; + const fetchImpl = function () { + // API echoes the key in its error body (some misconfigured proxies do this). + return Promise.resolve(makeErrorResponse(401, { + error: { message: 'Bad auth for key ' + secret } + })); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ + type: 'send', + config: Object.assign({}, validConfig, { apiKey: secret }), + messages: validMessages + }); + + return new Promise(function (resolve) { setTimeout(resolve, 20); }).then(function () { + // No posted message may contain the secret anywhere in its serialized form. + fake.posted.forEach(function (m) { + JSON.stringify(m).should.not.contain(secret); + }); + }); + }); + }); + + describe('cancellation', function () { + it('should abort the in-flight fetch when the port receives {type:cancel}', function () { + const fake = createFakePort(); + let capturedSignal = null; + let readReject = null; + + const fetchImpl = function (url, options) { + capturedSignal = options.signal; + return Promise.resolve({ + ok: true, + status: 200, + body: { + getReader: function () { + return { + read: function () { + return new Promise(function (_, reject) { + readReject = reject; + }); + }, + cancel: function () { return Promise.resolve(); } + }; + } + } + }); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 10); }).then(function () { + capturedSignal.aborted.should.be.false; + fake.deliver({ type: 'cancel' }); + capturedSignal.aborted.should.be.true; + if (readReject) { + const err = new Error('Aborted'); + err.name = 'AbortError'; + readReject(err); + } + return new Promise(function (resolve) { setTimeout(resolve, 10); }); + }).then(function () { + // After cancel, no `complete` should ever be posted for this run. + const completes = fake.posted.filter(function (m) { return m.type === 'complete'; }); + completes.should.have.length(0); + }); + }); + + it('should abort the in-flight fetch when the port disconnects', function () { + const fake = createFakePort(); + let capturedSignal = null; + let readReject = null; + + const fetchImpl = function (url, options) { + capturedSignal = options.signal; + return Promise.resolve({ + ok: true, + status: 200, + body: { + getReader: function () { + return { + read: function () { + return new Promise(function (_, reject) { + readReject = reject; + }); + }, + cancel: function () { return Promise.resolve(); } + }; + } + } + }); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 10); }).then(function () { + capturedSignal.aborted.should.be.false; + fake.triggerDisconnect(); + capturedSignal.aborted.should.be.true; + if (readReject) { + const err = new Error('Aborted'); + err.name = 'AbortError'; + readReject(err); + } + }); + }); + + it('should not post `complete` or further chunks after cancel arrives mid-stream', function () { + const fake = createFakePort(); + const encoder = new TextEncoder(); + let capturedSignal = null; + let firstReadResolve = null; + let secondReadResolve = null; + + const fetchImpl = function (url, options) { + capturedSignal = options.signal; + let readCount = 0; + return Promise.resolve({ + ok: true, + status: 200, + body: { + getReader: function () { + return { + read: function () { + readCount++; + if (readCount === 1) { + return new Promise(function (resolve) { + firstReadResolve = resolve; + }); + } + return new Promise(function (resolve, reject) { + secondReadResolve = { resolve: resolve, reject: reject }; + }); + }, + cancel: function () { return Promise.resolve(); } + }; + } + } + }); + }; + + attachOpenAIHandler(fake.port, { fetch: fetchImpl }); + fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + + return new Promise(function (resolve) { setTimeout(resolve, 10); }).then(function () { + // Deliver the first SSE chunk. Handler will post `chunk` and issue another read. + firstReadResolve({ done: false, value: encoder.encode(sseContent('first')) }); + return new Promise(function (resolve) { setTimeout(resolve, 10); }); + }).then(function () { + // Cancel before the second read resolves. + fake.deliver({ type: 'cancel' }); + capturedSignal.aborted.should.be.true; + // Reject the pending read with AbortError. + const err = new Error('Aborted'); + err.name = 'AbortError'; + secondReadResolve.reject(err); + return new Promise(function (resolve) { setTimeout(resolve, 10); }); + }).then(function () { + const completes = fake.posted.filter(function (m) { return m.type === 'complete'; }); + completes.should.have.length(0); + const chunks = fake.posted.filter(function (m) { return m.type === 'chunk'; }); + chunks.map(function (c) { return c.content; }).should.deep.equal(['first']); + const errors = fake.posted.filter(function (m) { return m.type === 'error'; }); + errors.should.have.length(0); + }); + }); + }); +});