From 32dc7c3e8d2b05078aa0fd9d6539193d2f090a53 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Mon, 6 Jul 2026 12:07:34 +0300 Subject: [PATCH 1/6] feat(ai): add OpenAIProvider behind the registry Implements Slice 3 of multi-provider-support. - OpenAIProvider: fetch + SSE streaming, Bearer auth, [DONE] sentinel, cross-read line buffering, cancellation via AbortSignal, API-error surfacing without leaking apiKey. checkAvailability returns 'ready' only when baseUrl, apiKey, and model are all set (no network ping). - Registered under 'openai' with displayName 'OpenAI-compatible' and a configSchema for baseUrl / apiKey / model. Gemini Nano stays the default; OpenAI is registered but not user-selectable until the settings UI lands in a later slice. - Spec covers SSE split-chunk buffering, [DONE] sentinel, 401/404/429 error surfacing, apiKey redaction, cancellation, and checkAvailability config-presence logic. Fake fetch injected at the constructor seam. - Add AbortSignal to jshintrc globals (Chrome-supported alongside the already-present AbortController). --- .jshintrc | 3 +- app/scripts/modules/ai/OpenAIProvider.js | 183 +++++++++ app/scripts/modules/ai/providers/index.js | 10 + tests/modules/ai/OpenAIProvider.spec.js | 428 ++++++++++++++++++++++ 4 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 app/scripts/modules/ai/OpenAIProvider.js create mode 100644 tests/modules/ai/OpenAIProvider.spec.js 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/scripts/modules/ai/OpenAIProvider.js b/app/scripts/modules/ai/OpenAIProvider.js new file mode 100644 index 00000000..eea9f7a1 --- /dev/null +++ b/app/scripts/modules/ai/OpenAIProvider.js @@ -0,0 +1,183 @@ +'use strict'; + +/** + * AI Provider backed by any OpenAI-compatible HTTP endpoint (real OpenAI, Ollama, LM Studio, Groq, + * etc.). Streams responses via SSE. All network I/O goes through `options.fetch`, defaulting to + * `window.fetch`, so tests can inject a fake at the constructor seam. + * + * @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.fetch] - Test seam. Defaults to the global `fetch`. + * @constructor + */ +function OpenAIProvider(config) { + const cfg = config || {}; + this._baseUrl = cfg.baseUrl || ''; + this._apiKey = cfg.apiKey || ''; + this._model = cfg.model || ''; + this._fetch = cfg.fetch || (typeof window !== 'undefined' && window.fetch ? window.fetch.bind(window) : null); + this._abortController = null; +} + +const DONE = Symbol('sse-done'); + +function abortError() { + const err = new Error('Aborted'); + err.name = 'AbortError'; + return err; +} + +function extractErrorMessage(response) { + return response.json().then( + function (body) { + if (body && body.error && body.error.message) { + return body.error.message; + } + return 'HTTP ' + response.status; + }, + function () { + return 'HTTP ' + response.status; + } + ); +} + +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 readSseStream(body, onChunk, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let fullText = ''; + + function pump() { + if (signal && signal.aborted) { + reader.cancel(); + return Promise.reject(abortError()); + } + return reader.read().then(function (result) { + if (result.done) { + return fullText; + } + 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(); + return fullText; + } + if (delta) { + fullText += delta; + if (typeof onChunk === 'function') { + onChunk(delta); + } + } + separatorIdx = buffer.indexOf('\n\n'); + } + return pump(); + }, function (err) { + if (signal && signal.aborted) { + throw abortError(); + } + throw err; + }); + } + + return pump(); +} + +/** + * Return `ready` iff `baseUrl`, `apiKey`, and `model` are all set. No network ping. + * @returns {Promise<{status: string, message: string}>} + */ +OpenAIProvider.prototype.checkAvailability = function () { + if (this._baseUrl && this._apiKey && this._model) { + return Promise.resolve({ status: 'ready', message: 'Ready' }); + } + return Promise.resolve({ status: 'unavailable', message: 'not configured' }); +}; + +/** + * POST `messages` to `${baseUrl}/chat/completions` with `stream: true`, parse the SSE response, + * forward content deltas via `onChunk`, and resolve with the accumulated full text. + * + * @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()); + } + + const internalController = new AbortController(); + this._abortController = internalController; + const combinedSignal = opts.signal ? + AbortSignal.any([opts.signal, internalController.signal]) : + internalController.signal; + + const url = this._baseUrl + '/chat/completions'; + const body = JSON.stringify({ + model: this._model, + messages: messages, + stream: true + }); + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + this._apiKey + }; + + return this._fetch(url, { + method: 'POST', + headers: headers, + body: body, + signal: combinedSignal + }).then(function (response) { + if (!response.ok) { + return extractErrorMessage(response).then(function (msg) { + throw new Error(msg); + }); + } + return readSseStream(response.body, opts.onChunk, combinedSignal); + }); +}; + +OpenAIProvider.prototype.destroy = function () { + if (this._abortController) { + this._abortController.abort(); + this._abortController = 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/tests/modules/ai/OpenAIProvider.spec.js b/tests/modules/ai/OpenAIProvider.spec.js new file mode 100644 index 00000000..56b9e2a6 --- /dev/null +++ b/tests/modules/ai/OpenAIProvider.spec.js @@ -0,0 +1,428 @@ +'use strict'; + +const OpenAIProvider = require('../../../app/scripts/modules/ai/OpenAIProvider.js'); + +function sseEvent(data) { + return 'data: ' + JSON.stringify(data) + '\n\n'; +} + +function contentEvent(text) { + return sseEvent({ choices: [{ delta: { content: text } }] }); +} + +function createResponse({ ok = true, status = 200, chunks = [], errorBody = null } = {}) { + let cursor = 0; + const encoder = new TextEncoder(); + + const body = { + getReader: function () { + return { + read: function () { + if (cursor >= chunks.length) { + return Promise.resolve({ done: true, value: undefined }); + } + const chunk = chunks[cursor++]; + return Promise.resolve({ + done: false, + value: typeof chunk === 'string' ? encoder.encode(chunk) : chunk + }); + }, + cancel: function () { + cursor = chunks.length; + return Promise.resolve(); + } + }; + } + }; + + return { + ok: ok, + status: status, + body: body, + json: function () { + return Promise.resolve(errorBody || {}); + }, + text: function () { + return Promise.resolve(errorBody ? JSON.stringify(errorBody) : ''); + } + }; +} + +function createFakeFetch(response) { + const calls = []; + const fetch = function (url, options) { + calls.push({ url: url, options: options }); + if (typeof response === 'function') { + return Promise.resolve(response({ url: url, options: options })); + } + return Promise.resolve(response); + }; + fetch.calls = calls; + return fetch; +} + +function defaultConfig(overrides) { + return Object.assign({ + baseUrl: 'http://localhost:6655/openai/v1', + apiKey: 'secret-key', + model: 'gpt-5.4' + }, overrides || {}); +} + +describe('OpenAIProvider', function () { + describe('#checkAvailability()', function () { + it('should return `ready` when baseUrl, apiKey, and model are all present', function () { + const provider = new OpenAIProvider(defaultConfig()); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('ready'); + }); + }); + + it('should return `unavailable` when baseUrl is missing', function () { + const provider = new OpenAIProvider(defaultConfig({ 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 = new OpenAIProvider(defaultConfig({ apiKey: '' })); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('unavailable'); + }); + }); + + it('should return `unavailable` when model is missing', function () { + const provider = new OpenAIProvider(defaultConfig({ model: '' })); + return provider.checkAvailability().then(function (result) { + result.status.should.equal('unavailable'); + }); + }); + + it('should not make any network request', function () { + const fetch = createFakeFetch(createResponse()); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.checkAvailability().then(function () { + fetch.calls.length.should.equal(0); + }); + }); + }); + + describe('#sendMessage() — request wiring', function () { + it('should POST to `${baseUrl}/chat/completions` with the messages, model, and stream:true', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [contentEvent('hi'), 'data: [DONE]\n\n'] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + const messages = [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Hello' } + ]; + return provider.sendMessage(messages).then(function () { + fetch.calls.length.should.equal(1); + fetch.calls[0].url.should.equal('http://localhost:6655/openai/v1/chat/completions'); + fetch.calls[0].options.method.should.equal('POST'); + const body = JSON.parse(fetch.calls[0].options.body); + body.model.should.equal('gpt-5.4'); + body.stream.should.equal(true); + body.messages.should.deep.equal(messages); + }); + }); + + it('should include a Bearer Authorization header with the API key', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [contentEvent('hi'), 'data: [DONE]\n\n'] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + const headers = fetch.calls[0].options.headers; + headers.Authorization.should.equal('Bearer secret-key'); + headers['Content-Type'].should.equal('application/json'); + }); + }); + }); + + describe('#sendMessage() — SSE parsing', function () { + it('should accumulate content deltas across chunks and resolve with the full text', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [ + contentEvent('Hello, '), + contentEvent('world'), + contentEvent('!'), + 'data: [DONE]\n\n' + ] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + const received = []; + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }], + { onChunk: function (t) { received.push(t); } } + ).then(function (fullText) { + received.should.deep.equal(['Hello, ', 'world', '!']); + fullText.should.equal('Hello, world!'); + }); + }); + + it('should buffer partial lines split across reads', function () { + const event = contentEvent('streamed'); + const midpoint = Math.floor(event.length / 2); + const fetch = createFakeFetch(createResponse({ + chunks: [ + event.slice(0, midpoint), + event.slice(midpoint), + 'data: [DONE]\n\n' + ] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }] + ).then(function (fullText) { + fullText.should.equal('streamed'); + }); + }); + + it('should handle multiple SSE events packed in one read', function () { + const packed = contentEvent('one') + contentEvent('two') + contentEvent('three') + 'data: [DONE]\n\n'; + const fetch = createFakeFetch(createResponse({ chunks: [packed] })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }] + ).then(function (fullText) { + fullText.should.equal('onetwothree'); + }); + }); + + it('should ignore SSE events whose delta has no content field (e.g. role-only opener)', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [ + sseEvent({ choices: [{ delta: { role: 'assistant' } }] }), + contentEvent('body'), + 'data: [DONE]\n\n' + ] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }] + ).then(function (fullText) { + fullText.should.equal('body'); + }); + }); + + it('should stop cleanly at the [DONE] sentinel without treating it as JSON', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [contentEvent('done-test'), 'data: [DONE]\n\n', contentEvent('after')] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }] + ).then(function (fullText) { + fullText.should.equal('done-test'); + }); + }); + }); + + describe('#sendMessage() — error surfacing', function () { + it('should reject with the API error message on 401', function () { + const fetch = createFakeFetch(createResponse({ + ok: false, + status: 401, + errorBody: { error: { message: 'Invalid API key' } } + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.message.should.contain('Invalid API key'); + }); + }); + + it('should reject with the API error message on 404', function () { + const fetch = createFakeFetch(createResponse({ + ok: false, + status: 404, + errorBody: { error: { message: 'Model not found' } } + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.message.should.contain('Model not found'); + }); + }); + + it('should reject with the API error message on 429', function () { + const fetch = createFakeFetch(createResponse({ + ok: false, + status: 429, + errorBody: { error: { message: 'Rate limited' } } + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.message.should.contain('Rate limited'); + }); + }); + + it('should never include the API key in error messages', function () { + const fetch = createFakeFetch(createResponse({ + ok: false, + status: 401, + errorBody: { error: { message: 'Bad auth' } } + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig({ apiKey: 'super-secret-abc123' }), { fetch: fetch })); + return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.message.should.not.contain('super-secret-abc123'); + }); + }); + + it('should reject with a generic status message when the API body is not parseable', function () { + const fetch = function () { + return Promise.resolve({ + ok: false, + status: 500, + body: null, + json: function () { return Promise.reject(new Error('bad json')); }, + text: function () { return Promise.resolve(''); } + }); + }; + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.message.should.contain('500'); + }); + }); + }); + + describe('#sendMessage() — cancellation', function () { + it('should reject with an AbortError when the signal is already aborted', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [contentEvent('x'), 'data: [DONE]\n\n'] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + 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 forward caller-signal aborts to the fetch call', function () { + const fetch = createFakeFetch(createResponse({ + chunks: [contentEvent('x'), 'data: [DONE]\n\n'] + })); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + const controller = new AbortController(); + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }], + { signal: controller.signal } + ).then(function () { + const fetchSignal = fetch.calls[0].options.signal; + (typeof fetchSignal === 'object').should.equal(true); + (fetchSignal.aborted === false).should.equal(true); + controller.abort(); + (fetchSignal.aborted === true).should.equal(true); + }); + }); + + it('should reject with AbortError when the signal aborts mid-stream', function () { + const controller = new AbortController(); + let readCount = 0; + const encoder = new TextEncoder(); + + const response = { + ok: true, + status: 200, + body: { + getReader: function () { + return { + read: function () { + readCount++; + if (readCount === 1) { + return Promise.resolve({ + done: false, + value: encoder.encode(contentEvent('first')) + }); + } + controller.abort(); + const err = new Error('Aborted'); + err.name = 'AbortError'; + return Promise.reject(err); + }, + cancel: function () { + return Promise.resolve(); + } + }; + } + } + }; + + const fetch = createFakeFetch(response); + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + + return provider.sendMessage( + [{ role: 'user', content: 'Hi' }], + { signal: controller.signal } + ).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.name.should.equal('AbortError'); + }); + }); + }); + + describe('#destroy()', function () { + it('should abort any in-flight request', function () { + let capturedSignal = null; + const response = { + ok: true, + status: 200, + body: { + getReader: function () { + return { + read: function () { + return new Promise(function (resolve, reject) { + capturedSignal.addEventListener('abort', function () { + const err = new Error('Aborted'); + err.name = 'AbortError'; + reject(err); + }); + }); + }, + cancel: function () { return Promise.resolve(); } + }; + } + } + }; + + const fetch = function (url, options) { + capturedSignal = options.signal; + return Promise.resolve(response); + }; + + const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + const sendPromise = provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + throw new Error('Expected reject'); + }, function (err) { + err.name.should.equal('AbortError'); + }); + + // Let the fetch resolve and read to start. + return Promise.resolve().then(function () { + return Promise.resolve(); + }).then(function () { + provider.destroy(); + return sendPromise; + }); + }); + }); +}); From 6a9cffd6e14b5116854f5a61aec068484ae23f76 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Mon, 6 Jul 2026 14:33:43 +0300 Subject: [PATCH 2/6] refactor(ai): move OpenAIProvider network I/O into service worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Slice 3.5 of multi-provider-support. The panel's CSP (default-src 'self') blocks cross-origin fetch, so all network I/O for the OpenAI-compatible provider now runs in the background service worker. Panel-side OpenAIProvider becomes a thin port-protocol client, symmetric to GeminiNanoProvider. - OpenAIProvider (panel): connects lazily to a chrome.runtime port named 'openai-api', posts {type:send, config, messages}, routes chunk / complete / error frames. Cancellation posts {type:cancel}. destroy() posts cancel and disconnects. No fetch or SSE parsing in the panel. - openaiHandler (background): new module, one AbortController per port. On send, fetches ${baseUrl}/chat/completions, parses the SSE stream, posts chunk / complete / error frames back. Redacts config.apiKey out of any error message it echoes back over the wire. Aborts on cancel or port disconnect. - main.js dispatches port.name === 'openai-api' to attachOpenAIHandler alongside the existing 'prompt-api' branch. - OpenAIProvider.spec.js rewritten in the fake-port style used by GeminiNanoProvider.spec.js: request-message shape, chunk/complete/error handling, cancellation posts {type:cancel}, disconnect surfaces an error, destroy disconnects. - New openaiHandler.spec.js covers SSE split-chunk buffering, [DONE] sentinel, 401/404/429 error surfacing, apiKey redaction (asserted as a full-message substring check), fetch rejection, cancel aborts the in-flight fetch, and port disconnect aborts the fetch. Manifest CSP and host_permissions were already in the clean state on this branch — no revert needed. (cherry picked from commit 0e79d64537cf8073ea6f64ea66aef8f4cf4db5c6) --- app/scripts/background/main.js | 3 + app/scripts/modules/ai/OpenAIProvider.js | 225 ++++---- .../modules/background/openaiHandler.js | 177 ++++++ tests/modules/ai/OpenAIProvider.spec.js | 505 +++++++----------- .../modules/background/openaiHandler.spec.js | 468 ++++++++++++++++ 5 files changed, 937 insertions(+), 441 deletions(-) create mode 100644 app/scripts/modules/background/openaiHandler.js create mode 100644 tests/modules/background/openaiHandler.spec.js 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/OpenAIProvider.js b/app/scripts/modules/ai/OpenAIProvider.js index eea9f7a1..94c79e34 100644 --- a/app/scripts/modules/ai/OpenAIProvider.js +++ b/app/scripts/modules/ai/OpenAIProvider.js @@ -1,15 +1,15 @@ 'use strict'; /** - * AI Provider backed by any OpenAI-compatible HTTP endpoint (real OpenAI, Ollama, LM Studio, Groq, - * etc.). Streams responses via SSE. All network I/O goes through `options.fetch`, defaulting to - * `window.fetch`, so tests can inject a fake at the constructor seam. + * 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.fetch] - Test seam. Defaults to the global `fetch`. + * @param {Function} [config.portFactory] - Test seam. Defaults to a `chrome.runtime.connect` call. * @constructor */ function OpenAIProvider(config) { @@ -17,103 +17,50 @@ function OpenAIProvider(config) { this._baseUrl = cfg.baseUrl || ''; this._apiKey = cfg.apiKey || ''; this._model = cfg.model || ''; - this._fetch = cfg.fetch || (typeof window !== 'undefined' && window.fetch ? window.fetch.bind(window) : null); - this._abortController = null; + this._portFactory = cfg.portFactory || function () { + return chrome.runtime.connect({ name: 'openai-api' }); + }; + this._port = null; + this._isConnected = false; + this._messageHandlers = {}; + this._disconnectHandler = null; } -const DONE = Symbol('sse-done'); - function abortError() { const err = new Error('Aborted'); err.name = 'AbortError'; return err; } -function extractErrorMessage(response) { - return response.json().then( - function (body) { - if (body && body.error && body.error.message) { - return body.error.message; - } - return 'HTTP ' + response.status; - }, - function () { - return 'HTTP ' + response.status; - } - ); -} - -function parseSseEvent(event) { - const trimmed = event.trim(); - if (!trimmed.startsWith('data:')) { - return ''; - } - const payload = trimmed.slice(5).trim(); - if (payload === '[DONE]') { - return DONE; +OpenAIProvider.prototype._connect = function () { + if (this._isConnected) { + return; } - 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 readSseStream(body, onChunk, signal) { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let fullText = ''; + this._port = this._portFactory(); + this._isConnected = true; - function pump() { - if (signal && signal.aborted) { - reader.cancel(); - return Promise.reject(abortError()); + this._port.onMessage.addListener((message) => { + const handler = this._messageHandlers[message.type]; + if (handler) { + handler(message); } - return reader.read().then(function (result) { - if (result.done) { - return fullText; - } - 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(); - return fullText; - } - if (delta) { - fullText += delta; - if (typeof onChunk === 'function') { - onChunk(delta); - } - } - separatorIdx = buffer.indexOf('\n\n'); - } - return pump(); - }, function (err) { - if (signal && signal.aborted) { - throw abortError(); - } - throw err; - }); - } + this._port.onDisconnect.addListener(() => { + this._isConnected = false; + this._port = null; - return pump(); -} + if (this._disconnectHandler) { + const h = this._disconnectHandler; + this._disconnectHandler = null; + h(); + } + }); +}; /** - * Return `ready` iff `baseUrl`, `apiKey`, and `model` are all set. No network ping. + * 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 () { @@ -124,8 +71,8 @@ OpenAIProvider.prototype.checkAvailability = function () { }; /** - * POST `messages` to `${baseUrl}/chat/completions` with `stream: true`, parse the SSE response, - * forward content deltas via `onChunk`, and resolve with the accumulated full text. + * 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] @@ -141,43 +88,87 @@ OpenAIProvider.prototype.sendMessage = function (messages, options) { return Promise.reject(abortError()); } - const internalController = new AbortController(); - this._abortController = internalController; - const combinedSignal = opts.signal ? - AbortSignal.any([opts.signal, internalController.signal]) : - internalController.signal; - - const url = this._baseUrl + '/chat/completions'; - const body = JSON.stringify({ - model: this._model, - messages: messages, - stream: true - }); - const headers = { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + this._apiKey - }; + 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); + } + }; - return this._fetch(url, { - method: 'POST', - headers: headers, - body: body, - signal: combinedSignal - }).then(function (response) { - if (!response.ok) { - return extractErrorMessage(response).then(function (msg) { - throw new Error(msg); - }); + 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); } - return readSseStream(response.body, opts.onChunk, combinedSignal); + + this._connect(); + this._port.postMessage({ + type: 'send', + config: { + baseUrl: this._baseUrl, + apiKey: this._apiKey, + model: this._model + }, + messages: messages + }); }); }; +/** + * Cancel any in-flight request and disconnect the port. + */ OpenAIProvider.prototype.destroy = function () { - if (this._abortController) { - this._abortController.abort(); - this._abortController = null; + 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/background/openaiHandler.js b/app/scripts/modules/background/openaiHandler.js new file mode 100644 index 00000000..83b8072a --- /dev/null +++ b/app/scripts/modules/background/openaiHandler.js @@ -0,0 +1,177 @@ +'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; diff --git a/tests/modules/ai/OpenAIProvider.spec.js b/tests/modules/ai/OpenAIProvider.spec.js index 56b9e2a6..d6d5ffec 100644 --- a/tests/modules/ai/OpenAIProvider.spec.js +++ b/tests/modules/ai/OpenAIProvider.spec.js @@ -2,63 +2,45 @@ const OpenAIProvider = require('../../../app/scripts/modules/ai/OpenAIProvider.js'); -function sseEvent(data) { - return 'data: ' + JSON.stringify(data) + '\n\n'; -} - -function contentEvent(text) { - return sseEvent({ choices: [{ delta: { content: text } }] }); -} +function createFakePort() { + const messageListeners = []; + const disconnectListeners = []; -function createResponse({ ok = true, status = 200, chunks = [], errorBody = null } = {}) { - let cursor = 0; - const encoder = new TextEncoder(); - - const body = { - getReader: function () { - return { - read: function () { - if (cursor >= chunks.length) { - return Promise.resolve({ done: true, value: undefined }); - } - const chunk = chunks[cursor++]; - return Promise.resolve({ - done: false, - value: typeof chunk === 'string' ? encoder.encode(chunk) : chunk - }); - }, - cancel: function () { - cursor = chunks.length; - return Promise.resolve(); - } - }; - } + 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 { - ok: ok, - status: status, - body: body, - json: function () { - return Promise.resolve(errorBody || {}); + port: port, + posted: port.posted, + emit: function (message) { + messageListeners.forEach(function (listener) { + listener(message); + }); }, - text: function () { - return Promise.resolve(errorBody ? JSON.stringify(errorBody) : ''); - } - }; -} - -function createFakeFetch(response) { - const calls = []; - const fetch = function (url, options) { - calls.push({ url: url, options: options }); - if (typeof response === 'function') { - return Promise.resolve(response({ url: url, options: options })); + triggerDisconnect: function () { + disconnectListeners.forEach(function (listener) { + listener(); + }); } - return Promise.resolve(response); }; - fetch.calls = calls; - return fetch; } function defaultConfig(overrides) { @@ -69,17 +51,25 @@ function defaultConfig(overrides) { }, 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 = new OpenAIProvider(defaultConfig()); + const { provider } = createProvider(); return provider.checkAvailability().then(function (result) { result.status.should.equal('ready'); }); }); it('should return `unavailable` when baseUrl is missing', function () { - const provider = new OpenAIProvider(defaultConfig({ baseUrl: '' })); + const { provider } = createProvider({ baseUrl: '' }); return provider.checkAvailability().then(function (result) { result.status.should.equal('unavailable'); result.message.should.contain('not configured'); @@ -87,342 +77,209 @@ describe('OpenAIProvider', function () { }); it('should return `unavailable` when apiKey is missing', function () { - const provider = new OpenAIProvider(defaultConfig({ apiKey: '' })); + 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 = new OpenAIProvider(defaultConfig({ model: '' })); + const { provider } = createProvider({ model: '' }); return provider.checkAvailability().then(function (result) { result.status.should.equal('unavailable'); }); }); - it('should not make any network request', function () { - const fetch = createFakeFetch(createResponse()); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + it('should not post anything on the port', function () { + const { provider, fake } = createProvider(); return provider.checkAvailability().then(function () { - fetch.calls.length.should.equal(0); + fake.posted.should.have.length(0); }); }); }); - describe('#sendMessage() — request wiring', function () { - it('should POST to `${baseUrl}/chat/completions` with the messages, model, and stream:true', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [contentEvent('hi'), 'data: [DONE]\n\n'] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + 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' } ]; - return provider.sendMessage(messages).then(function () { - fetch.calls.length.should.equal(1); - fetch.calls[0].url.should.equal('http://localhost:6655/openai/v1/chat/completions'); - fetch.calls[0].options.method.should.equal('POST'); - const body = JSON.parse(fetch.calls[0].options.body); - body.model.should.equal('gpt-5.4'); - body.stream.should.equal(true); - body.messages.should.deep.equal(messages); - }); - }); - it('should include a Bearer Authorization header with the API key', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [contentEvent('hi'), 'data: [DONE]\n\n'] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { - const headers = fetch.calls[0].options.headers; - headers.Authorization.should.equal('Bearer secret-key'); - headers['Content-Type'].should.equal('application/json'); + 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; }); - }); - describe('#sendMessage() — SSE parsing', function () { - it('should accumulate content deltas across chunks and resolve with the full text', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [ - contentEvent('Hello, '), - contentEvent('world'), - contentEvent('!'), - 'data: [DONE]\n\n' - ] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + it('should forward chunk messages via onChunk and resolve with the accumulated text on complete', async function () { + const { provider, fake } = createProvider(); const received = []; - return provider.sendMessage( + const sendPromise = provider.sendMessage( [{ role: 'user', content: 'Hi' }], { onChunk: function (t) { received.push(t); } } - ).then(function (fullText) { - received.should.deep.equal(['Hello, ', 'world', '!']); - fullText.should.equal('Hello, world!'); - }); - }); + ); - it('should buffer partial lines split across reads', function () { - const event = contentEvent('streamed'); - const midpoint = Math.floor(event.length / 2); - const fetch = createFakeFetch(createResponse({ - chunks: [ - event.slice(0, midpoint), - event.slice(midpoint), - 'data: [DONE]\n\n' - ] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage( - [{ role: 'user', content: 'Hi' }] - ).then(function (fullText) { - fullText.should.equal('streamed'); - }); - }); + await Promise.resolve(); + fake.emit({ type: 'chunk', content: 'Hello, ' }); + fake.emit({ type: 'chunk', content: 'world' }); + fake.emit({ type: 'chunk', content: '!' }); + fake.emit({ type: 'complete' }); - it('should handle multiple SSE events packed in one read', function () { - const packed = contentEvent('one') + contentEvent('two') + contentEvent('three') + 'data: [DONE]\n\n'; - const fetch = createFakeFetch(createResponse({ chunks: [packed] })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage( - [{ role: 'user', content: 'Hi' }] - ).then(function (fullText) { - fullText.should.equal('onetwothree'); - }); + const full = await sendPromise; + full.should.equal('Hello, world!'); + received.should.deep.equal(['Hello, ', 'world', '!']); }); - it('should ignore SSE events whose delta has no content field (e.g. role-only opener)', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [ - sseEvent({ choices: [{ delta: { role: 'assistant' } }] }), - contentEvent('body'), - 'data: [DONE]\n\n' - ] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage( - [{ role: 'user', content: 'Hi' }] - ).then(function (fullText) { - fullText.should.equal('body'); - }); - }); + 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' }]); - it('should stop cleanly at the [DONE] sentinel without treating it as JSON', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [contentEvent('done-test'), 'data: [DONE]\n\n', contentEvent('after')] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage( - [{ role: 'user', content: 'Hi' }] - ).then(function (fullText) { - fullText.should.equal('done-test'); - }); - }); - }); + await Promise.resolve(); + fake.emit({ type: 'error', message: 'Invalid API key' }); - describe('#sendMessage() — error surfacing', function () { - it('should reject with the API error message on 401', function () { - const fetch = createFakeFetch(createResponse({ - ok: false, - status: 401, - errorBody: { error: { message: 'Invalid API key' } } - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + try { + await sendPromise; throw new Error('Expected reject'); - }, function (err) { - err.message.should.contain('Invalid API key'); - }); + } catch (err) { + err.message.should.equal('Invalid API key'); + } }); - it('should reject with the API error message on 404', function () { - const fetch = createFakeFetch(createResponse({ - ok: false, - status: 404, - errorBody: { error: { message: 'Model not found' } } - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { - throw new Error('Expected reject'); - }, function (err) { - err.message.should.contain('Model not found'); - }); - }); + it('should reject when the port disconnects mid-request', async function () { + const { provider, fake } = createProvider(); + const sendPromise = provider.sendMessage([{ role: 'user', content: 'Hi' }]); - it('should reject with the API error message on 429', function () { - const fetch = createFakeFetch(createResponse({ - ok: false, - status: 429, - errorBody: { error: { message: 'Rate limited' } } - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { - throw new Error('Expected reject'); - }, function (err) { - err.message.should.contain('Rate limited'); - }); - }); + await Promise.resolve(); + fake.triggerDisconnect(); - it('should never include the API key in error messages', function () { - const fetch = createFakeFetch(createResponse({ - ok: false, - status: 401, - errorBody: { error: { message: 'Bad auth' } } - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig({ apiKey: 'super-secret-abc123' }), { fetch: fetch })); - return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { + try { + await sendPromise; throw new Error('Expected reject'); - }, function (err) { - err.message.should.not.contain('super-secret-abc123'); - }); - }); - - it('should reject with a generic status message when the API body is not parseable', function () { - const fetch = function () { - return Promise.resolve({ - ok: false, - status: 500, - body: null, - json: function () { return Promise.reject(new Error('bad json')); }, - text: function () { return Promise.resolve(''); } - }); - }; - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - return provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { - throw new Error('Expected reject'); - }, function (err) { - err.message.should.contain('500'); - }); + } catch (err) { + err.message.should.contain('Connection'); + } }); }); describe('#sendMessage() — cancellation', function () { - it('should reject with an AbortError when the signal is already aborted', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [contentEvent('x'), 'data: [DONE]\n\n'] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + 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(); - controller.abort(); - return provider.sendMessage( + + const sendPromise = provider.sendMessage( [{ role: 'user', content: 'Hi' }], { signal: controller.signal } - ).then(function () { + ); + + await Promise.resolve(); + fake.emit({ type: 'chunk', content: 'partial' }); + controller.abort(); + + try { + await sendPromise; throw new Error('Expected reject'); - }, function (err) { + } catch (err) { err.name.should.equal('AbortError'); - }); - }); + } - it('should forward caller-signal aborts to the fetch call', function () { - const fetch = createFakeFetch(createResponse({ - chunks: [contentEvent('x'), 'data: [DONE]\n\n'] - })); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - const controller = new AbortController(); - return provider.sendMessage( - [{ role: 'user', content: 'Hi' }], - { signal: controller.signal } - ).then(function () { - const fetchSignal = fetch.calls[0].options.signal; - (typeof fetchSignal === 'object').should.equal(true); - (fetchSignal.aborted === false).should.equal(true); - controller.abort(); - (fetchSignal.aborted === true).should.equal(true); - }); + const cancelPosts = fake.posted.filter(function (m) { return m.type === 'cancel'; }); + cancelPosts.should.have.length(1); }); - it('should reject with AbortError when the signal aborts mid-stream', function () { + it('should ignore chunks that arrive after the signal aborts', async function () { + const { provider, fake } = createProvider(); const controller = new AbortController(); - let readCount = 0; - const encoder = new TextEncoder(); - - const response = { - ok: true, - status: 200, - body: { - getReader: function () { - return { - read: function () { - readCount++; - if (readCount === 1) { - return Promise.resolve({ - done: false, - value: encoder.encode(contentEvent('first')) - }); - } - controller.abort(); - const err = new Error('Aborted'); - err.name = 'AbortError'; - return Promise.reject(err); - }, - cancel: function () { - return Promise.resolve(); - } - }; - } + 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(); - const fetch = createFakeFetch(response); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); + // Late chunk after abort — must not be surfaced. + fake.emit({ type: 'chunk', content: 'late' }); + fake.emit({ type: 'complete' }); - return provider.sendMessage( - [{ role: 'user', content: 'Hi' }], - { signal: controller.signal } - ).then(function () { + try { + await sendPromise; throw new Error('Expected reject'); - }, function (err) { + } catch (err) { err.name.should.equal('AbortError'); - }); + } + + received.should.deep.equal(['first']); }); }); describe('#destroy()', function () { - it('should abort any in-flight request', function () { - let capturedSignal = null; - const response = { - ok: true, - status: 200, - body: { - getReader: function () { - return { - read: function () { - return new Promise(function (resolve, reject) { - capturedSignal.addEventListener('abort', function () { - const err = new Error('Aborted'); - err.name = 'AbortError'; - reject(err); - }); - }); - }, - cancel: function () { return Promise.resolve(); } - }; - } - } - }; + 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 */ }); - const fetch = function (url, options) { - capturedSignal = options.signal; - return Promise.resolve(response); - }; + await Promise.resolve(); + provider.destroy(); - const provider = new OpenAIProvider(Object.assign(defaultConfig(), { fetch: fetch })); - const sendPromise = provider.sendMessage([{ role: 'user', content: 'Hi' }]).then(function () { - throw new Error('Expected reject'); - }, function (err) { - err.name.should.equal('AbortError'); - }); + fake.port.disconnected.should.be.true; + }); - // Let the fetch resolve and read to start. - return Promise.resolve().then(function () { - return Promise.resolve(); - }).then(function () { - provider.destroy(); - return sendPromise; - }); + 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); }); }); }); diff --git a/tests/modules/background/openaiHandler.spec.js b/tests/modules/background/openaiHandler.spec.js new file mode 100644 index 00000000..64657574 --- /dev/null +++ b/tests/modules/background/openaiHandler.spec.js @@ -0,0 +1,468 @@ +'use strict'; + +const attachOpenAIHandler = require('../../../app/scripts/modules/background/openaiHandler.js'); + +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']); + }); + }); + + it('should stop at [DONE] and not process further chunks', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeStreamingResponse(encodeChunks([ + sseContent('done-test'), + 'data: [DONE]\n\n', + sseContent('after') + ]))); + }; + + 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(['done-test']); + }); + }); + }); + + 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)); } + }; + } + + it('should post {type:error} with the API `error.message` on 401', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeErrorResponse(401, { error: { message: 'Invalid API key' } })); + }; + + 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('Invalid API key'); + }); + }); + + it('should post {type:error} with the API `error.message` on 404', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeErrorResponse(404, { error: { message: 'Model not found' } })); + }; + + 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('Model not found'); + }); + }); + + it('should post {type:error} with the API `error.message` on 429', function () { + const fake = createFakePort(); + const fetchImpl = function () { + return Promise.resolve(makeErrorResponse(429, { error: { message: 'Rate limited' } })); + }; + + 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('Rate limited'); + }); + }); + + 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); + }); + }); + }); +}); From 14af4992a958412e42c42e63ff0c33f3afd29d30 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Mon, 6 Jul 2026 14:48:31 +0300 Subject: [PATCH 3/6] fix(ai): allow service-worker fetch through extension_pages CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3.5 moved OpenAIProvider's HTTP I/O into the background service worker on the theory that host_permissions alone would cover the cross-origin fetch. That theory is wrong on current Chrome — the extension_pages CSP applies to the service worker too, and its default-src 'self' falls back for connect-src, blocking any endpoint. Add an explicit connect-src using CSP scheme-source expressions: connect-src 'self' http: https: The scheme-source form ("http:", "https:") is what Chrome's CSP parser accepts for allow-any-host-over-scheme. Host-source with a bare wildcard ("http://*") parses without error but does not match any host — a subtle CSP gotcha that cost us a debugging round. No new attack surface: the extension already declares access to every http(s) origin via host_permissions. Slice 4's settings UI can tighten this to user-specified origins if that becomes worthwhile. (cherry picked from commit 830f4ad01d547e6394e80046309cc3259135c34c) --- app/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 363257aa7d4714dec4c924942473804f9e00c459 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Mon, 6 Jul 2026 16:04:39 +0300 Subject: [PATCH 4/6] fix(ai): stop mislabelling the banner as Gemini for other providers AssistantController had three hard-coded 'Gemini Nano is ready' strings that clobbered the banner whenever setUrl, clearConversation, or a post-streaming-failure recovery ran. With OpenAI configured, opening DevTools showed the correct provider banner momentarily, then setUrl fired and the banner reverted to 'Gemini Nano is ready'. Cache the last known ready message from the provider's own checkAvailability, and re-emit that on every subsequent ready transition. Also give OpenAIProvider a self-identifying ready message ('OpenAI-compatible () ready') so the banner names the active provider instead of a generic 'Ready'. (cherry picked from commit 2b8673871ac4cc32ecd047a930f7d8ef8a2db696) --- app/scripts/modules/ai/AssistantController.js | 10 +++++-- app/scripts/modules/ai/OpenAIProvider.js | 2 +- tests/modules/ai/AssistantController.spec.js | 29 +++++++++++++++++++ tests/modules/ai/OpenAIProvider.spec.js | 8 +++++ 4 files changed, 45 insertions(+), 4 deletions(-) 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 index 94c79e34..2dce91fa 100644 --- a/app/scripts/modules/ai/OpenAIProvider.js +++ b/app/scripts/modules/ai/OpenAIProvider.js @@ -65,7 +65,7 @@ OpenAIProvider.prototype._connect = function () { */ OpenAIProvider.prototype.checkAvailability = function () { if (this._baseUrl && this._apiKey && this._model) { - return Promise.resolve({ status: 'ready', message: 'Ready' }); + return Promise.resolve({ status: 'ready', message: 'OpenAI-compatible (' + this._model + ') ready' }); } return Promise.resolve({ status: 'unavailable', message: 'not configured' }); }; 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 index d6d5ffec..9b32064b 100644 --- a/tests/modules/ai/OpenAIProvider.spec.js +++ b/tests/modules/ai/OpenAIProvider.spec.js @@ -68,6 +68,14 @@ describe('OpenAIProvider', function () { }); }); + 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) { From 0402f6d49d4d21fc00053234e121175cb22e4220 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Wed, 2 Sep 2026 15:11:57 +0300 Subject: [PATCH 5/6] test(ai): tighten openaiHandler tests Collapse the identical 401/404/429 error tests into one table-driven loop, and export parseSseEvent so the SSE edge cases become direct unit tests instead of full fetch+stream+port harness setups. Adds coverage for keep-alive lines, non-JSON payloads, and empty choices. --- .../modules/background/openaiHandler.js | 2 + .../modules/background/openaiHandler.spec.js | 104 ++++++++---------- 2 files changed, 46 insertions(+), 60 deletions(-) diff --git a/app/scripts/modules/background/openaiHandler.js b/app/scripts/modules/background/openaiHandler.js index 83b8072a..a1f5522f 100644 --- a/app/scripts/modules/background/openaiHandler.js +++ b/app/scripts/modules/background/openaiHandler.js @@ -175,3 +175,5 @@ function attachOpenAIHandler(port, options) { } module.exports = attachOpenAIHandler; +module.exports.parseSseEvent = parseSseEvent; +module.exports.DONE = DONE; diff --git a/tests/modules/background/openaiHandler.spec.js b/tests/modules/background/openaiHandler.spec.js index 64657574..07a497bc 100644 --- a/tests/modules/background/openaiHandler.spec.js +++ b/tests/modules/background/openaiHandler.spec.js @@ -1,6 +1,8 @@ 'use strict'; const attachOpenAIHandler = require('../../../app/scripts/modules/background/openaiHandler.js'); +const parseSseEvent = attachOpenAIHandler.parseSseEvent; +const DONE = attachOpenAIHandler.DONE; function createFakePort() { const messageListeners = []; @@ -177,24 +179,32 @@ describe('openaiHandler', function () { chunks.map(function (c) { return c.content; }).should.deep.equal(['body']); }); }); + }); - it('should stop at [DONE] and not process further chunks', function () { - const fake = createFakePort(); - const fetchImpl = function () { - return Promise.resolve(makeStreamingResponse(encodeChunks([ - sseContent('done-test'), - 'data: [DONE]\n\n', - sseContent('after') - ]))); - }; + describe('parseSseEvent', function () { + it('should return the delta content string for a content event', function () { + parseSseEvent(sseContent('hello')).should.equal('hello'); + }); - attachOpenAIHandler(fake.port, { fetch: fetchImpl }); - fake.deliver({ type: 'send', config: validConfig, messages: validMessages }); + it('should return DONE for the [DONE] sentinel', function () { + parseSseEvent('data: [DONE]').should.equal(DONE); + }); - 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(['done-test']); - }); + 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(''); }); }); @@ -208,51 +218,25 @@ describe('openaiHandler', function () { }; } - it('should post {type:error} with the API `error.message` on 401', function () { - const fake = createFakePort(); - const fetchImpl = function () { - return Promise.resolve(makeErrorResponse(401, { error: { message: 'Invalid API key' } })); - }; - - 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('Invalid API key'); - }); - }); - - it('should post {type:error} with the API `error.message` on 404', function () { - const fake = createFakePort(); - const fetchImpl = function () { - return Promise.resolve(makeErrorResponse(404, { error: { message: 'Model not found' } })); - }; - - 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('Model not found'); - }); - }); - - it('should post {type:error} with the API `error.message` on 429', function () { - const fake = createFakePort(); - const fetchImpl = function () { - return Promise.resolve(makeErrorResponse(429, { error: { message: 'Rate limited' } })); - }; - - 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('Rate limited'); + [ + { 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); + }); }); }); From b49bd8fb824468bbf3b5b38b5f6b320c3f65783a Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Wed, 2 Sep 2026 15:52:53 +0300 Subject: [PATCH 6/6] fix(ai): implement getUsageInfo and settle in-flight promise on destroy OpenAIProvider was missing getUsageInfo, which AssistantController calls unconditionally after every stream-complete. With OpenAI selected the first reply threw a synchronous TypeError that escaped the panel's promise chain. Add a stub returning null; the token pill already handles null by leaving its last state in place. destroy() also cleared _disconnectHandler without invoking it, so a clear/setUrl during an active stream left the sendMessage promise permanently pending, leaking the port and abort listener. Reject the pending promise before tearing down the port. --- app/scripts/modules/ai/OpenAIProvider.js | 14 ++++++++++++++ tests/modules/ai/OpenAIProvider.spec.js | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/app/scripts/modules/ai/OpenAIProvider.js b/app/scripts/modules/ai/OpenAIProvider.js index 2dce91fa..35c1239a 100644 --- a/app/scripts/modules/ai/OpenAIProvider.js +++ b/app/scripts/modules/ai/OpenAIProvider.js @@ -157,10 +157,24 @@ OpenAIProvider.prototype.sendMessage = function (messages, options) { }); }; +/** + * 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(); diff --git a/tests/modules/ai/OpenAIProvider.spec.js b/tests/modules/ai/OpenAIProvider.spec.js index 9b32064b..eabd5bc0 100644 --- a/tests/modules/ai/OpenAIProvider.spec.js +++ b/tests/modules/ai/OpenAIProvider.spec.js @@ -289,5 +289,29 @@ describe('OpenAIProvider', function () { 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; + }); + }); }); });