diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index d0330ca..61a7931 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -12,6 +12,25 @@ automatically and can't silently regress; prefer that over prose where possible. --- +## Default provider switched from Anthropic to OpenRouter + GLM 5.2 + +**What changed:** `DEFAULT_SETTINGS.activeProvider` is now `openai_compat` pointing at +`https://openrouter.ai/api/v1` with model `z-ai/glm-5.2`. The previous default was `anthropic / +claude-sonnet-4-6`. + +**Why:** GLM 5.2 (Z.ai via OpenRouter) costs ~$1.20/1M input + $4.10/1M output tokens versus +Claude Sonnet 4.6 at $3/1M + $15/1M — roughly 3-4× cheaper for Slopwatch's prompt shape (dense +paragraph analysis with a large system prompt). This validates AD-4 ("OpenAI-compat adapter as +gateway"). The Anthropic adapter remains first-class; users who already configured Anthropic keep +it via the v1 settings round-trip. + +**What to watch:** GLM 5.2 is a reasoning model with a different response style than Claude. +Calibration (score distribution, false-positive rate on clearly-human text) should be monitored +in early beta. The `overall` score range and `reasoning` quality may differ. If detection quality +regresses, switching back is a one-line change in `DEFAULT_SETTINGS`. + +--- + ## Comment/review-triggered agent workflows must gate on collaborator permission BEFORE checkout **Symptom:** `claude-pr-feedback.yml` fired on any `@claude` comment/review with no actor diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 3af2e86..0ab473a 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -35,7 +35,9 @@ type TestState = { status: 'idle' | 'testing' | 'ok' | 'fail'; detail?: string } export function App() { const [settings, setSettings] = useState(null); const [anthropicConfigured, setConfigured] = useState(false); + const [openaiCompatConfigured, setOpenAICompatConfigured] = useState(false); const [keyInput, setKeyInput] = useState(''); + const [openaiCompatKeyInput, setOpenAICompatKeyInput] = useState(''); const [test, setTest] = useState>({}); const [ollamaModels, setOllamaModels] = useState(null); const [cacheEntries, setCacheEntries] = useState(null); @@ -45,6 +47,7 @@ export function App() { void (async () => { setSettings(await getSettings()); setConfigured(await hasSecret('anthropic')); + setOpenAICompatConfigured(await hasSecret('openai_compat')); setCacheEntries((await cacheStats()).entries); setDiag(await listDiagnostics()); })(); @@ -102,6 +105,19 @@ export function App() { setConfigured(false); }; + const saveOpenAICompatKey = async () => { + if (!openaiCompatKeyInput.trim()) return; + await setSecret('openai_compat', openaiCompatKeyInput.trim(), settings.persistSecrets); + setOpenAICompatConfigured(true); + setOpenAICompatKeyInput(''); + await patch({ activeProvider: 'openai_compat', onboarded: true }); + }; + + const clearOpenAICompatKey = async () => { + await clearSecret('openai_compat'); + setOpenAICompatConfigured(false); + }; + const togglePersist = async (checked: boolean) => { await patch({ persistSecrets: checked }); await applyPersistencePreference(checked); @@ -197,6 +213,49 @@ export function App() { testConnection('anthropic')} /> +
+

OpenAI-compatible

+

+ Works with OpenRouter, vanilla OpenAI, Azure OpenAI, and other OpenAI-compatible + gateways. The default is OpenRouter + GLM 5.2. +

+ + setProviderField('openai_compat', 'baseUrl', e.target.value)} + /> + + setProviderField('openai_compat', 'model', e.target.value)} + /> + + {openaiCompatConfigured ? ( +

+ Configured ✓{' '} + +

+ ) : ( +
+ setOpenAICompatKeyInput(e.target.value)} + /> + +
+ )} +

+ Your key goes directly from your browser to the gateway and is never sent to any Slopwatch + server. Prefer a scoped or limited key. +

+ testConnection('openai_compat')} /> +
+

Ollama (local)

Runs on your device. Nothing leaves your machine.

@@ -365,5 +424,5 @@ const PROVIDER_LABELS: Record = { // The Mock provider is only offered in development builds — never in production. const PROVIDER_OPTIONS: ProviderId[] = import.meta.env.DEV - ? ['anthropic', 'ollama', 'mock'] - : ['anthropic', 'ollama']; + ? ['anthropic', 'openai_compat', 'ollama', 'mock'] + : ['anthropic', 'openai_compat', 'ollama']; diff --git a/lib/providers/index.ts b/lib/providers/index.ts index 6ca808d..482da50 100644 --- a/lib/providers/index.ts +++ b/lib/providers/index.ts @@ -4,6 +4,7 @@ import { getSecret } from '../storage/secrets'; import { MockProvider } from './mock'; import { AnthropicProvider } from './anthropic'; import { OllamaProvider } from './ollama'; +import { OpenAICompatProvider } from './openai-compat'; import { ProviderError } from '../errors'; /** @@ -31,12 +32,10 @@ export async function createProvider(settings: Settings): Promise= 200 && status < 300, text: async () => body } as unknown as Response; +} + +function httpWith(fetchImpl: typeof fetch): HttpDeps { + return { fetchImpl, sleep: async () => {}, timeoutMs: 1000, maxRetries: 2, backoff: [1, 1, 1] }; +} + +const analysisPayload = JSON.stringify({ + overall: 0.7, + reasoning: 'looks AI-generated', + segments: [{ index: 0, aiLikelihood: 0.8, rationale: 'formulaic phrasing' }], +}); + +const validBody = JSON.stringify({ + choices: [{ message: { content: analysisPayload } }], + usage: { prompt_tokens: 800, completion_tokens: 150 }, +}); + +describe('OpenAICompatProvider', () => { + it('sends bearer auth plus attribution headers and maps a result with usage and cost', async () => { + let captured: RequestInit | undefined; + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + captured = init; + return resp(200, validBody); + }) as unknown as typeof fetch; + + const p = new OpenAICompatProvider( + 'z-ai/glm-5.2', + 'https://openrouter.ai/api/v1', + 'sk-test', + thresholds, + httpWith(fetchImpl), + ); + const r = await p.analyze(content(), new AbortController().signal); + + const headers = captured!.headers as Record; + expect(headers['Authorization']).toBe('Bearer sk-test'); + expect(headers['HTTP-Referer']).toBeDefined(); + expect(headers['X-Title']).toBe('Slopwatch'); + + expect(r.provider).toBe('openai_compat'); + expect(r.overall).toBe(0.7); + expect(r.label).toBe('likely-ai'); + expect(r.ranLocally).toBe(false); + expect(r.usage?.inputTokens).toBe(800); + expect(r.usage?.outputTokens).toBe(150); + expect(r.usage?.estCostUsd).toBeGreaterThan(0); + }); + + it('sends json_schema in response_format on the first call', async () => { + let parsedBody: unknown; + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + parsedBody = JSON.parse(init!.body as string); + return resp(200, validBody); + }) as unknown as typeof fetch; + + const p = new OpenAICompatProvider('z-ai/glm-5.2', 'https://openrouter.ai/api/v1', 'key', thresholds, httpWith(fetchImpl)); + await p.analyze(content(), new AbortController().signal); + + const body = parsedBody as { response_format?: { type?: string } }; + expect(body.response_format?.type).toBe('json_schema'); + }); + + it('falls back to json_object when the gateway rejects json_schema with a 400', async () => { + const rejectBody = JSON.stringify({ error: { message: 'json_schema response_format not supported', code: 'unsupported_value' } }); + let callCount = 0; + let lastBody: unknown; + + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + callCount++; + lastBody = JSON.parse(init!.body as string); + if (callCount === 1) return resp(400, rejectBody); + return resp(200, validBody); + }) as unknown as typeof fetch; + + const p = new OpenAICompatProvider('z-ai/glm-5.2', 'https://openrouter.ai/api/v1', 'key', thresholds, httpWith(fetchImpl)); + const r = await p.analyze(content(), new AbortController().signal); + + expect(callCount).toBe(2); + expect((lastBody as { response_format?: { type?: string } }).response_format?.type).toBe('json_object'); + expect(r.overall).toBe(0.7); + }); + + it('does NOT fall back for a 400 unrelated to json_schema (e.g. bad model name)', async () => { + const unrelatedError = JSON.stringify({ error: { message: 'model not found', code: 'model_not_found' } }); + const fetchImpl = vi.fn(async () => resp(400, unrelatedError)) as unknown as typeof fetch; + + const p = new OpenAICompatProvider('bad-model', 'https://openrouter.ai/api/v1', 'key', thresholds, httpWith(fetchImpl)); + await expect(p.analyze(content(), new AbortController().signal)).rejects.toMatchObject({ + kind: 'bad_response', + }); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(1); + }); + + it('maps 401 to a non-retryable auth error', async () => { + const fetchImpl = vi.fn(async () => resp(401, '{"error":"bad key"}')) as unknown as typeof fetch; + const p = new OpenAICompatProvider('m', 'https://openrouter.ai/api/v1', 'k', thresholds, httpWith(fetchImpl)); + await expect(p.analyze(content(), new AbortController().signal)).rejects.toMatchObject({ + kind: 'auth', + retryable: false, + }); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(1); + }); + + it('maps 429 to a retryable rate_limit error', async () => { + const fetchImpl = vi.fn(async () => resp(429, 'slow down')) as unknown as typeof fetch; + const p = new OpenAICompatProvider('m', 'https://openrouter.ai/api/v1', 'k', thresholds, httpWith(fetchImpl)); + await expect(p.analyze(content(), new AbortController().signal)).rejects.toMatchObject({ + kind: 'rate_limit', + retryable: true, + }); + }); + + it('repairs once when the first response is unparseable', async () => { + const badBody = JSON.stringify({ choices: [{ message: { content: 'not json at all' } }] }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(resp(200, badBody)) + .mockResolvedValueOnce(resp(200, validBody)) as unknown as typeof fetch; + + const p = new OpenAICompatProvider('m', 'https://openrouter.ai/api/v1', 'k', thresholds, httpWith(fetchImpl)); + const r = await p.analyze(content(), new AbortController().signal); + + expect(r.meta.schemaRepaired).toBe(true); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(2); + }); + + it('without a key: validate fails and analyze throws auth', async () => { + const p = new OpenAICompatProvider('m', 'https://openrouter.ai/api/v1', undefined, thresholds, httpWith(vi.fn() as unknown as typeof fetch)); + expect((await p.validate()).ok).toBe(false); + await expect(p.analyze(content(), new AbortController().signal)).rejects.toMatchObject({ + kind: 'auth', + }); + }); + + it('validate returns ok on a successful round-trip', async () => { + const fetchImpl = vi.fn(async () => resp(200, JSON.stringify({ choices: [] }))) as unknown as typeof fetch; + const p = new OpenAICompatProvider('z-ai/glm-5.2', 'https://openrouter.ai/api/v1', 'key', thresholds, httpWith(fetchImpl)); + const result = await p.validate(); + expect(result.ok).toBe(true); + expect(result.detail).toContain('z-ai/glm-5.2'); + }); + + it('strips trailing slash from base URL', async () => { + let url = ''; + const fetchImpl = vi.fn(async (u: string) => { + url = u; + return resp(200, validBody); + }) as unknown as typeof fetch; + + const p = new OpenAICompatProvider('m', 'https://openrouter.ai/api/v1/', 'k', thresholds, httpWith(fetchImpl)); + await p.analyze(content(), new AbortController().signal); + expect(url).toBe('https://openrouter.ai/api/v1/chat/completions'); + }); +}); + +describe('estimateCost (openai-compat)', () => { + it('prices known model slugs and returns undefined for unknown ones', () => { + expect(estimateCost('z-ai/glm-5.2', 1_000_000, 1_000_000)).toBeCloseTo(1.2 + 4.1); + expect(estimateCost('gpt-4o-mini', 1_000_000, 1_000_000)).toBeCloseTo(0.15 + 0.6); + expect(estimateCost('some-unknown-model', 1000, 1000)).toBeUndefined(); + }); +}); diff --git a/lib/providers/openai-compat.ts b/lib/providers/openai-compat.ts new file mode 100644 index 0000000..07f6a8b --- /dev/null +++ b/lib/providers/openai-compat.ts @@ -0,0 +1,194 @@ +import type { AnalysisProvider, AnalysisResult, AnalysisUsage, ExtractedContent } from '../types'; +import type { Thresholds } from '../storage/settings'; +import { ProviderError } from '../errors'; +import { buildPrompt } from '../analysis/prompt'; +import { RESPONSE_JSON_SCHEMA } from '../analysis/schema'; +import { finalizeResult, parseWithRepair } from './base'; +import { requestJson, DEFAULT_HTTP, type HttpDeps } from './http'; + +/** + * OpenAI-compatible API adapter (Story 7). Works with any gateway that speaks the + * OpenAI Chat Completions format — OpenAI, OpenRouter, Azure OpenAI, local proxies, + * etc. Structured output is requested via `response_format.json_schema`; if the + * gateway returns 400 with a body indicating it doesn't support that mode, the + * adapter falls back once to `response_format.json_object` without silently hiding + * other 4xx errors. Attribution headers (`HTTP-Referer`, `X-Title`) are always sent + * and are harmless on non-OpenRouter gateways. + */ + +const MAX_TOKENS = 2048; +const LABEL = 'OpenAI-compatible'; + +/** + * Best-effort cost table (USD per 1M tokens). Returns undefined for unknown models + * rather than crashing. Updated when the default provider changes. + * + * z-ai/glm-5.2 pricing from https://openrouter.ai/api/v1/models (2026-06-21): + * input $1.20/1M, output $4.10/1M. + */ +const PRICING: { match: string; in: number; out: number }[] = [ + { match: 'z-ai/glm-5.2', in: 1.2, out: 4.1 }, + { match: 'gpt-4o-mini', in: 0.15, out: 0.6 }, +]; + +interface OpenAICompatResponse { + choices?: { message?: { content?: string } }[]; + usage?: { prompt_tokens?: number; completion_tokens?: number }; +} + +export class OpenAICompatProvider implements AnalysisProvider { + readonly id = 'openai_compat' as const; + private readonly base: string; + + constructor( + private readonly model: string, + baseUrl: string | undefined, + private readonly apiKey: string | undefined, + private readonly thresholds: Thresholds, + private readonly http: HttpDeps = DEFAULT_HTTP, + ) { + this.base = (baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, ''); + } + + private headers(): Record { + if (!this.apiKey) { + throw new ProviderError('auth', 'No OpenAI-compatible API key is configured.', { + retryable: false, + }); + } + return { + Authorization: `Bearer ${this.apiKey}`, + 'HTTP-Referer': 'https://github.com/DisplaceTech/slopwatch', + 'X-Title': 'Slopwatch', + }; + } + + async validate(signal?: AbortSignal): Promise<{ ok: boolean; detail?: string }> { + if (!this.apiKey) return { ok: false, detail: 'No OpenAI-compatible API key is configured.' }; + try { + await requestJson( + { + url: `${this.base}/chat/completions`, + headers: this.headers(), + body: { + model: this.model, + max_tokens: 1, + messages: [{ role: 'user', content: 'ping' }], + }, + signal: signal ?? new AbortController().signal, + providerLabel: LABEL, + }, + this.http, + ); + return { ok: true, detail: `Connected to OpenAI-compatible endpoint (${this.model}).` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } + } + + async analyze(content: ExtractedContent, signal: AbortSignal): Promise { + const headers = this.headers(); + const { system, user } = buildPrompt(content); + const start = Date.now(); + let usage: AnalysisUsage | undefined; + let useJsonObject = false; + + const call = async (userText: string): Promise => { + const responseFormat = useJsonObject + ? { type: 'json_object' } + : { + type: 'json_schema', + json_schema: { name: 'slopwatch_analysis', strict: true, schema: RESPONSE_JSON_SCHEMA }, + }; + + const data = (await requestJson( + { + url: `${this.base}/chat/completions`, + headers, + body: { + model: this.model, + max_tokens: MAX_TOKENS, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: userText }, + ], + response_format: responseFormat, + }, + signal, + providerLabel: LABEL, + }, + this.http, + )) as OpenAICompatResponse; + + usage = this.usageFrom(data); + return (data.choices ?? [])[0]?.message?.content ?? ''; + }; + + let firstText: string; + try { + firstText = await call(user); + } catch (err) { + if (isJsonSchemaUnsupported(err)) { + // Gateway doesn't support json_schema structured output; fall back once to + // json_object (which all OpenAI-compatible gateways must support). We do NOT + // fall back silently for any other 4xx. + useJsonObject = true; + firstText = await call(user); + } else { + throw err; + } + } + + const { analysis, repaired } = await parseWithRepair(firstText, (validationError) => + call( + `${user}\n\nYour previous response could not be parsed (${validationError}). ` + + `Return ONLY the corrected JSON object, no prose.`, + ), + ); + + return finalizeResult({ + analysis, + provider: 'openai_compat', + model: this.model, + ranLocally: false, + thresholds: this.thresholds, + latencyMs: Date.now() - start, + truncated: content.truncated, + sampledFraction: content.sampledFraction, + schemaRepaired: repaired, + segmentCount: content.segments.length, + usage, + }); + } + + private usageFrom(data: OpenAICompatResponse): AnalysisUsage | undefined { + const inputTokens = data.usage?.prompt_tokens; + const outputTokens = data.usage?.completion_tokens; + if (inputTokens === undefined && outputTokens === undefined) return undefined; + return { + inputTokens, + outputTokens, + estCostUsd: estimateCost(this.model, inputTokens ?? 0, outputTokens ?? 0), + }; + } +} + +/** True when a `ProviderError` signals that the gateway rejected `json_schema`. */ +function isJsonSchemaUnsupported(err: unknown): boolean { + return ( + err instanceof ProviderError && + err.kind === 'bad_response' && + typeof err.detail === 'string' && + /json_schema|response_format|unsupported/i.test(err.detail) + ); +} + +export function estimateCost( + model: string, + inputTokens: number, + outputTokens: number, +): number | undefined { + const price = PRICING.find((p) => model.toLowerCase().includes(p.match)); + if (!price) return undefined; + return (inputTokens / 1_000_000) * price.in + (outputTokens / 1_000_000) * price.out; +} diff --git a/lib/storage/secrets.test.ts b/lib/storage/secrets.test.ts new file mode 100644 index 0000000..89d4f0d --- /dev/null +++ b/lib/storage/secrets.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { + setSecret, + getSecret, + hasSecret, + clearSecret, + applyPersistencePreference, +} from './secrets'; + +describe('secrets storage', () => { + it('stores and retrieves a secret from session storage (default)', async () => { + await setSecret('anthropic', 'sk-ant-test', false); + expect(await getSecret('anthropic')).toBe('sk-ant-test'); + expect(await hasSecret('anthropic')).toBe(true); + }); + + it('stores a secret in local storage when persist=true', async () => { + await setSecret('openai_compat', 'or-test-key', true); + expect(await getSecret('openai_compat')).toBe('or-test-key'); + expect(await hasSecret('openai_compat')).toBe(true); + }); + + it('clears a secret from both areas', async () => { + await setSecret('anthropic', 'k', false); + await clearSecret('anthropic'); + expect(await hasSecret('anthropic')).toBe(false); + }); + + it('applyPersistencePreference migrates an openai_compat secret to local', async () => { + await setSecret('openai_compat', 'or-migrate-key', false); + expect(await hasSecret('openai_compat')).toBe(true); + + await applyPersistencePreference(true); + + // Secret is still accessible after migration. + expect(await getSecret('openai_compat')).toBe('or-migrate-key'); + expect(await hasSecret('openai_compat')).toBe(true); + }); + + it('applyPersistencePreference migrates openai_compat back to session', async () => { + await setSecret('openai_compat', 'or-session-key', true); + await applyPersistencePreference(false); + expect(await getSecret('openai_compat')).toBe('or-session-key'); + }); + + it('applyPersistencePreference is a no-op for unset providers', async () => { + // No secret set for openai_compat — should not throw. + await expect(applyPersistencePreference(true)).resolves.toBeUndefined(); + expect(await hasSecret('openai_compat')).toBe(false); + }); +}); diff --git a/lib/storage/settings.ts b/lib/storage/settings.ts index 1a02de8..64a8f9c 100644 --- a/lib/storage/settings.ts +++ b/lib/storage/settings.ts @@ -64,15 +64,16 @@ export type Appearance = Settings['appearance']; export const DEFAULT_SETTINGS: Settings = { version: SETTINGS_VERSION, - // A real provider by default. The Mock provider is dev-only (see createProvider) - // — a fresh install is "not configured" and the UI prompts setup rather than - // silently producing fake results. - activeProvider: 'anthropic', + // Default to OpenRouter + GLM 5.2 (AD-4: OpenAI-compat as gateway). This is + // meaningfully cheaper than Claude Sonnet for Slopwatch's prompt shapes while + // maintaining detection quality. Existing users keep their configured provider + // via the v1 settings round-trip; this only affects fresh installs / resetSettings(). + activeProvider: 'openai_compat', providers: { - // Sonnet is the default for accuracy — Haiku tends to under-detect. Users - // can switch to a cheaper (haiku) or stronger (opus) model in settings. anthropic: { model: 'claude-sonnet-4-6' }, - openai_compat: { model: 'gpt-4o-mini', baseUrl: 'https://api.openai.com/v1' }, + // TODO(slopwatch): bump to z-ai/glm-5.2-YYYYMMDD canonical slug once that form + // is stable on OpenRouter, or keep the alias if it auto-tracks the latest version. + openai_compat: { model: 'z-ai/glm-5.2', baseUrl: 'https://openrouter.ai/api/v1' }, ollama: { model: 'llama3.1', baseUrl: 'http://localhost:11434' }, mock: { model: 'mock-1' }, }, diff --git a/tests/component/options.test.tsx b/tests/component/options.test.tsx index 90e6607..06f6594 100644 --- a/tests/component/options.test.tsx +++ b/tests/component/options.test.tsx @@ -15,8 +15,8 @@ describe('options', () => { expect(await screen.findByRole('heading', { name: /slopwatch settings/i })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: /anthropic/i })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: /ollama/i })).toBeInTheDocument(); - // Key field (not configured by default) is a password input — write-only. - const key = screen.getByLabelText(/api key/i) as HTMLInputElement; + // Anthropic key field (not configured by default) is a password input — write-only. + const key = screen.getByLabelText(/api key/i, { selector: '#anthropic-key' }) as HTMLInputElement; expect(key.type).toBe('password'); expect(screen.getByLabelText(/likely human below/i)).toBeInTheDocument(); expect(screen.getByLabelText(/highlight style/i)).toBeInTheDocument(); @@ -32,9 +32,11 @@ describe('options', () => { it('saves the key write-only (configured state, never echoed) and can clear it', async () => { render(); - const key = (await screen.findByLabelText(/api key/i)) as HTMLInputElement; + // Target the Anthropic key field specifically — the page has multiple "API key" inputs. + const key = (await screen.findByLabelText(/api key/i, { selector: '#anthropic-key' })) as HTMLInputElement; fireEvent.change(key, { target: { value: 'sk-ant-secret' } }); - fireEvent.click(screen.getByRole('button', { name: /save key/i })); + const [firstSaveKeyBtn] = screen.getAllByRole('button', { name: /save key/i }); + fireEvent.click(firstSaveKeyBtn!); await waitFor(() => expect(screen.getByText(/configured/i)).toBeInTheDocument()); // The secret is stored but never rendered back. diff --git a/tests/component/popup.test.tsx b/tests/component/popup.test.tsx index a2c5034..66f0a9f 100644 --- a/tests/component/popup.test.tsx +++ b/tests/component/popup.test.tsx @@ -48,7 +48,8 @@ describe('popup', () => { }); it('shows the Run button once a provider is configured', async () => { - await setSecret('anthropic', 'sk-ant-test', false); + // Default active provider is openai_compat; set its key so the popup unlocks. + await setSecret('openai_compat', 'or-test-key', false); render(); expect(await screen.findByRole('button', { name: /run analysis/i })).toBeInTheDocument(); expect(document.querySelector('.privacy')).toBeTruthy(); diff --git a/wxt.config.ts b/wxt.config.ts index 3ebc035..6873786 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ optional_host_permissions: [ 'https://api.anthropic.com/*', 'https://api.openai.com/*', + 'https://openrouter.ai/*', 'http://localhost/*', 'http://127.0.0.1/*', ],