Skip to content

feat(ai): OpenAI-compatible provider (stacked on #381) - #383

Open
dobrinyonkov wants to merge 17 commits into
split/pr1-provider-refactorfrom
split/pr2-openai-provider
Open

feat(ai): OpenAI-compatible provider (stacked on #381)#383
dobrinyonkov wants to merge 17 commits into
split/pr1-provider-refactorfrom
split/pr2-openai-provider

Conversation

@dobrinyonkov

Copy link
Copy Markdown
Contributor

What this does

Adds an OpenAI-compatible provider to the AI assistant, so you can point it at any OpenAI-style chat-completions endpoint instead of on-device Gemini Nano. Stacked on #381 (the provider refactor).

Stacking

This PR targets split/pr1-provider-refactor, not master. Review #381 first. Once PR1 merges, this retargets to master automatically and the diff shrinks to just the feature.

What is new

The genuinely new code is about 750 lines across four files:

  • OpenAIProvider.js — panel-side client. Talks to the background worker over a chrome.runtime port named openai-api. No network I/O here (the panel CSP blocks cross-origin fetch).
  • background/openaiHandler.js — background SSE handler. Runs the actual fetch, parses the stream, redacts the API key from any error text, aborts in-flight requests on cancel or disconnect.
  • AISettingsModal.js — provider picker and config form. Schema-driven, so adding a provider needs no modal changes. Focus trap, Escape, backdrop click.
  • providers/index.js — registers openai alongside gemini-nano, with a config schema (baseUrl, apiKey as password, model).

Plus small seams in AssistantController (setProvider hot-swap, per-provider feature detection) and AIChat (gear icon, not-configured banner, hiding the token counter for providers that lack usage info).

Security

The API key is never logged and never appears in error messages (redacted in the background handler). It renders as a password field and lives in chrome.storage.local, per-provider.

Testing

npx grunt karma:CI — 638 tests pass. OpenAI port protocol is covered by an integration spec.

Review path

Start with the four files above. The rest is wiring and tests.

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.
- Temporarily wires AIChat to the openai provider with the test
  gateway config so the end-to-end path can be validated before the
  settings UI lands in Slice 4. Both the hardcoded providerName swap
  and the inlined config are marked for removal in Slice 4.
- 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).
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.
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.
- AssistantController.setProvider(name, config): aborts in-flight
  stream via AbortController, destroys old provider, constructs new
  one via registry, re-checks availability. Conversation memory kept.
- sendUserMessage now propagates an AbortSignal to the provider.
  AbortError from the swap does not emit stream-failed nor flip
  capability to streaming-failed.
- AIChat gains providerName/providerConfig options; hardcoded gateway
  config removed.
- Panel wiring reads ai_provider_name and ai_provider_config from
  chrome.storage.local; falls back to gemini-nano with empty config.
- AssistantController.spec.js: setProvider tests covering old-provider
  destroy, factory args, history preservation, in-flight abort,
  capability re-emit, and quiet-abort behaviour.
Adds AISettingsModal — a proper modal (backdrop, focus trap, Esc to
close) opened from a gear icon in the AI Chat header. The modal reads
each provider's configSchema from the registry to render its form,
pre-fills from the stored per-provider config, and validates that all
required fields are non-empty before Save is enabled. Save persists
ai_provider_name and ai_provider_config to chrome.storage.local (merging
into any existing per-provider config so credentials for other providers
survive) and calls controller.setProvider(name, config).

AIChat gains three constructor options (providersRegistry, storage,
settingsModalFactory) so the modal path is fully injectable for tests
and the view keeps its production defaults.
- AssistantController.getUsageInfo now resolves to null when the current
  provider does not implement the optional method, per the PRD. Without
  this, switching from Gemini to OpenAI at runtime crashed the view's
  post-save token-counter refresh with 'getUsageInfo is not a function'.

- Group the download, clear-history, and settings buttons in a
  .banner-actions wrapper. The banner's justify-content: space-between
  was distributing the buttons individually, leaving a large gap between
  Clear History and the gear icon. The wrapper keeps them clustered.
Adds an optional `placeholder` field to configSchema entries and
threads it through to the rendered input. OpenAI-compatible now hints
'https://host/v1', 'your API key', and 'e.g. gpt-4o-mini' — generic
enough not to leak any real endpoint or credential.
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 (<model>) ready') so the banner names the active
provider instead of a generic 'Ready'.
…tion

Slice 6 of the multi-provider refactor.

- AssistantController.getProviderCapabilities() reports whether the
  currently-installed provider implements the optional downloadModel and
  getUsageInfo methods. AIChat consults it inside _renderCapabilityBanner
  and _updateTokenCounter, so switching from Gemini to OpenAI hides the
  token counter and download button on the next capability-state emission.
- OpenAIProvider.checkAvailability tags the missing-config unavailable
  result with reason: 'not-configured' and a user-facing 'Open settings
  to configure' message. The controller plumbs reason through
  _setCapabilityState so the view can distinguish it from browser-level
  unavailable reasons.
- AIChat gains a banner action button (id ai-banner-action) that is only
  shown when reason === 'not-configured'. Clicking it opens the settings
  modal via the same _openSettings path as the gear icon.
- Extended _CAPABILITY_CONFIG so 'unavailable' and 'unsupported' now
  carry disableInput: true. _onCapabilityStateChanged applies the flag
  via _applyInputDisabledState, and re-enables the input on 'ready'.

Manually validated with the mock harness: OpenAI mode hides the token
counter and download button; the not-configured banner shows the Open
settings action; clicking it opens the modal; the input is disabled on
unavailable/unsupported states.
…ing credentials

Two bugs surfaced during manual validation in the actual extension:

1. Token counter stayed visible when swapping from Gemini (active
   conversation) to OpenAI while OpenAI resolved to unavailable
   (missing config). _updateTokenCounter was only invoked on the
   'ready' state via the updateTokens config flag, so a swap that
   landed on unavailable never re-ran feature detection and the pill
   kept its Gemini value. Call _updateTokenCounter on every mapped
   capability transition. The hide-when-hasUsageInfo-false early
   return already handles the hide; the null-usage no-op still
   prevents mid-stream flicker on the ready path.

2. Settings modal disabled Save whenever any required field was
   empty. That prevented users from clearing credentials — e.g.
   removing an API key while keeping OpenAI selected. Drop the
   required-field validation entirely. Save is always enabled; the
   provider's checkAvailability surfaces missing config as an
   unavailable/not-configured capability state, which already drives
   the banner, banner action, and input-disabled UX.
Collapse three state-machine test clusters into table-driven form and
drop the redundant real-settings-modal end-to-end test.

- Empty-conversation gating: introduce runScenario + rebuild helpers,
  fold four terminal-state scenarios into one table-driven it(), merge
  the two attribute checks into one, merge the hide-on-clear and
  reveal-after-clear tests into one cycle test. 10 tests -> 5.
- Assistant Capability State routing: iterate class-name mapping and
  message-text surfacing internally in a single it() (excluding the
  downloading percent-format special case), iterate download-button
  visibility rule internally in a single it(). 12 tests -> 7 (unmapped,
  class-name, downloading class-name, download-button visibility,
  downloading progress-percent, session-failed clear-history, and
  streaming-failed skip).
- Inline error slot: one parametrised keydown-clears test over
  ['a', 'Control', 'Enter', 'ArrowLeft'] replacing the two dedicated
  keydown tests. Also drops the redundant 'resumes typing' input-event
  case, whose input-event _clearError coverage remains in the 'sends a
  new message' test.
- Settings modal: delete 'should drive the real settings modal
  end-to-end'; real-modal behaviour is covered in AISettingsModal.spec;
  wiring is covered by the six fake-modal tests that remain. Add a
  one-line orientation comment above the describe block.

Coverage preserved: every previous assertion has a home. karma:CI
green (638 total; 66 it() in AIChat.spec, was 77).

Note: the two numeric acceptance criteria (test count -15, LOC -250)
undershot at -11 tests and -114 LOC. Meeting them would have required
sacrificing coverage or dropping in-scope constraints from the safety
pre-check (e.g. re-folding 'downloading' into the class-name test).
Coverage was prioritised per pre-check bullets 2 and 3.
…ngsModal.spec

- Merge password/text input-type tests into one parametrised table, adding
  a coverage-expansion row for undefined type (defaults to 'text').
- Merge with-placeholder and no-placeholder tests into one parametrised
  table; keep hasAttribute() as the assertion axis so the 'no placeholder'
  row can't silently pass on any input.
- Delete 'should render zero form fields when the selected provider has an
  empty configSchema' — trivially covered by [].forEach being a no-op.

Test count in the file drops from 24 to 21 (−3). LOC drops from 350 to
329 (−21), which is below the ≥40 target in the issue; the arithmetic of
the parametrisation (5 tests removed × ~7 LOC, 2 parametrised tests
added × ~16 LOC) does not permit −40 without expanding scope beyond the
four target tests.

All 638 karma:CI tests pass.
- Extract _setClearHistoryVisible(bool): folds four call sites that
  looked up #ai-clear-history-button and toggled style.display
  ('conversation-loaded', 'conversation-cleared',
  _onCapabilityStateChanged, _handleSendMessage). The
  _onCapabilityStateChanged gate '(status !== ready || _hasMessages)'
  stays at the call site so per-status logic is unchanged.
- Extract _resetTokenCounterClasses(counter): keeps the two internal
  call sites in _updateTokenCounter but removes the duplicated class
  list. Folding into one unconditional reset was considered and
  rejected — the null-usageInfo path intentionally leaves prior
  classes on the pill to avoid flicker (see the existing 'Null usage'
  comment). The AC line 'exactly one place' conflicts with task 2's
  explicit permission for either shape; helper-extract is the
  behavior-preserving choice.
- Inline _checkModelAvailability(): the one-line wrapper is deleted
  and init() calls _controller.initialize() directly.
- Trim five JSDoc blocks (_render, _CAPABILITY_CONFIG, _hideContextPill,
  _showError, _clearError) to one-liners or delete where the method is
  obvious. Non-obvious invariants (transcript ownership of the messages
  container, unknown-status fallback, show-path asymmetry for the
  context pill, auto-clear behavior of the error slot) are preserved
  in the one-liners.

Net LOC saved: 24 (not the 60 the AC targeted; user-accepted).
All 638 unit tests remain green.
Two passes over the multi-provider-support work.

Pass 1 — simplifications (zero behavior change):
- GeminiNanoProvider: capability-state lookup table, .every() for
  messagesEqual, shared cleanup helper in downloadModel/_createSession
- openaiHandler: dedup fallback in extractErrorMessage; err?.message
  form kept as (err && err.message) || … (browserify 16 / acorn does
  not parse optional chaining)
- AssistantController: _msg(err, fallback) helper (4 sites), ternary
  getUsageInfo, single push with two args
- PromptBuilder: .filter(Boolean) for section accumulation, ternary
  helpers, filter().forEach() for history
- AIChat: fold _clearError into _showError(''), loop settings/banner
  click bindings, inline _resetTokenCounterClasses,
  updateContext(null) reuses _hideContextPill()
- AISettingsModal: drop dead dataset.required, data-driven click
  bindings

Pass 2 — dead code, each finding adversarially verified with two
independent Explore skeptics (2×14 = 28 refutation attempts). Only
findings both skeptics failed to refute were deleted; four claims
survived refutation and were kept, including a real race in the
debounced flush and a session-leak guard on the download path.

- AssistantController._isStreaming: 5 writes, 0 production readers
  (AIChat has its own separate flag)
- _CAPABILITY_CONFIG['session-failed']: no provider emits it; drop
  the entry, the LESS rule, and the associated guard branch
- background/main.js: session-destroyed port message had no listener
- AssistantTranscript.scrollToBottom: dead scrollHeight === undefined
  half of the guard
- AssistantTranscript.appendUserTurn/appendSystemMessage: discarded
  HTMLElement return values
- AssistantController.sendUserMessage: { content } resolution never
  read in production (stream-complete event delivers the same object)
- consoleErrorCapture.uninstall: never invoked in production
- AssistantTranscript._appendMessage: showCopyButton === true branch
  unreachable (no caller passes true)

Tests: 637 passing (dropped one obsolete session-failed spec).
JSHint: clean.
Net: 13 files, +93 −195 lines.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant