feat(ai): add Anthropic-compatible provider; fix async plugin backends

Anthropic-compatible provider:
- New 'anthropic-compatible' option for any base URL speaking the Anthropic
  messages schema (LiteLLM proxies, Bedrock gateways, local servers)
- Sends x-api-key AND Bearer auth when a key is set (gateway-friendly,
  harmless for the official API); keyless proxies supported
- Tolerates base URLs with or without a trailing /v1 segment
- Settings modal, manifest, and provider docs updated

Runtime bug fixes found by booting the app (run-to-verify pass):
- PDF editor: File > Open PDF sends operation=null which matched no switch
  case and crashed on getElementById(undefined); now defaults to the merge
  section
- backlinks-panel: wrong require depth (../../utils -> ../utils) threw at
  panel registration time
- writing-studio stack was written against a synchronous settings backend but
  the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and
  all four panels now await; JSON.parse(Promise) crashes eliminated
- manuscript panel used window.prompt (unavailable in Electron); replaced
  with an inline dialog
- collaboration comment-store/save-load made async to match its IPC IO

617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
This commit is contained in:
2026-09-05 21:53:34 +05:30
parent efca458495
commit 7ab5a0ddb4
20 changed files with 426 additions and 225 deletions
+50
View File
@@ -33,6 +33,10 @@ describe('AiProviders', () => {
it('does not require an API key for local providers', () => {
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
// Self-hosted Anthropic-compatible proxies may front their own auth
expect(() =>
resolveSettings({ provider: 'anthropic-compatible', baseUrl: 'http://localhost:4000' })
).not.toThrow();
});
it('requires an API key for remote providers', () => {
@@ -109,6 +113,52 @@ describe('AiProviders', () => {
expect(body.system).toBe('be nice');
});
it('routes anthropic-compatible to the custom base with Bearer + x-api-key auth', async () => {
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'proxy ok' }] });
const result = await complete(
{
provider: 'anthropic-compatible',
baseUrl: 'http://localhost:4000',
apiKey: 'proxy-key',
messages,
},
{ fetchImpl }
);
expect(result.content).toBe('proxy ok');
const [url, init] = fetchImpl.mock.calls[0];
// Base without a /v1 suffix gets one appended
expect(url).toBe('http://localhost:4000/v1/messages');
// Both auth styles are sent so LiteLLM/Bedrock-style gateways accept it
expect(init.headers['x-api-key']).toBe('proxy-key');
expect(init.headers.Authorization).toBe('Bearer proxy-key');
expect(init.headers['anthropic-version']).toBe('2023-06-01');
});
it('does not double the /v1 segment when the base already ends with it', async () => {
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'ok' }] });
await complete(
{
provider: 'anthropic-compatible',
baseUrl: 'http://localhost:4000/v1/',
messages,
},
{ fetchImpl }
);
// Trailing slash is stripped by resolveSettings, so the base ends in /v1
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('http://localhost:4000/v1/messages');
// Keyless proxies must work without auth headers
expect(init.headers.Authorization).toBeUndefined();
expect(init.headers['x-api-key']).toBeUndefined();
});
it('rejects anthropic-compatible without a base URL', async () => {
await expect(
complete({ provider: 'anthropic-compatible', messages }, { fetchImpl: okFetch({}) })
).rejects.toThrow(/Invalid API base URL/);
});
it('surfaces HTTP failures as user-safe errors without the body', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
await expect(