You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement the OpenAICompatProvider adapter (TDD Story 7) and switch the project's default backend to OpenRouter + GLM 5.2, which is cheaper than Anthropic Sonnet for this workload.
Context
The provider layer (lib/providers) is designed around three real adapters behind one interface — Anthropic, Ollama, and an OpenAI-compatible adapter that doubles as a gateway (TDD §3 AD-4). Anthropic and Ollama are live; the OpenAI-compat slot is a typed stub that throws in lib/providers/index.ts:34. Settings (openai_compat in lib/storage/settings.ts:39), runtime permissions (lib/permissions.ts:17), the readiness check (lib/providers/readiness.ts:15), the manifest's optional_host_permissions, and UI provider labels already reserve the slot — the missing piece is the adapter itself and the options-UI surface for it.
The trigger for doing this now: GLM 5.2 is available via OpenRouter at a meaningfully lower price than Claude Sonnet 4.6 (the current anthropic default in DEFAULT_SETTINGS) for prompts of Slopwatch's shape. Routing through OpenRouter via the OpenAI-compatible adapter lets us flip the default backend without adding a fourth provider type, and it validates the "OpenAI-compat as gateway" architectural decision.
The adapter must mirror the shape of the existing Anthropic and Ollama providers: validated structured output, one repair attempt, normalized errors, no raw HTTP bodies leaking to the UI, secrets that never round-trip through the renderer.
Acceptance Criteria
New file lib/providers/openai-compat.ts exports OpenAICompatProvider implementing AnalysisProvider from lib/types.ts
Adapter is wired into the factory in lib/providers/index.ts (replacing the current ProviderError throw for openai_compat)
analyze() POSTs to {baseUrl}/chat/completions with Authorization: Bearer <key>, content-type: application/json, and the OpenRouter attribution headers (HTTP-Referer: <extension origin>, X-Title: Slopwatch) — the attribution headers are harmless when the user points at vanilla OpenAI or another gateway
Structured output is requested via response_format: { type: "json_schema", json_schema: { name: "slopwatch_analysis", strict: true, schema: RESPONSE_JSON_SCHEMA } }. On a 400 response whose body indicates the gateway does not support json_schema (string-match on json_schema, response_format, or unsupported), retry once with response_format: { type: "json_object" }. On any other 4xx, propagate the typed ProviderError — do NOT silently fall back
Both the structured-output path and the json_object fallback path are unit-tested in lib/providers/openai-compat.test.ts (mirror the shape of anthropic.test.ts)
Schema validation goes through parseWithRepair from lib/providers/base.ts (one repair attempt on the assistant text before failing with bad_response)
validate() does a 1-token round trip and returns { ok, detail } — same contract as AnthropicProvider.validate()
usage is populated from the response's usage.prompt_tokens and usage.completion_tokens when present. Cost estimation is best-effort: a small PRICING table covering at least the configured GLM 5.2 slug and gpt-4o-mini, returning undefined for unknown models (no crash)
DEFAULT_SETTINGS.providers.openai_compat is updated to { model: '<openrouter glm-5.2 slug>', baseUrl: 'https://openrouter.ai/api/v1' }. Verify the exact slug against https://openrouter.ai/api/v1/models at implementation time; if GLM 5.2 is not yet listed under that name, use GLM 4.6's slug and leave a // TODO(slopwatch): to bump
DEFAULT_SETTINGS.activeProvider is changed from 'anthropic' to 'openai_compat'
The manifest's optional_host_permissions in wxt.config.ts includes https://openrouter.ai/* alongside the existing https://api.openai.com/* entry
Options UI (entrypoints/options/App.tsx) gains an OpenAI-compatible section — model field, base-URL field, API-key entry (write-only, masked "configured" state), and Test connection button — mirroring the Anthropic and Ollama sections. The provider must appear in PROVIDER_OPTIONS for production builds
applyPersistencePreference in lib/storage/secrets.ts already iterates 'openai_compat', so no change there; verify by adding a unit test that persists/clears an openai_compat secret
All existing tests still pass, and pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm build:firefox are clean
Scope
In scope:
The OpenAICompatProvider adapter and its unit tests
Factory wiring in lib/providers/index.ts
Default-settings flip to OpenRouter + GLM 5.2
Manifest optional_host_permissions addition for openrouter.ai
Options-UI section for configuring the provider
json_object fallback path triggered only by a schema-rejection 400
A note in docs/LEARNINGS.md recording the default-provider change (one paragraph: what changed, why GLM via OpenRouter, what to watch for in real-world results)
Out of scope:
Streaming responses (stream: true) — we use blocking JSON
Tool/function-calling integration — JSON output is sufficient
A model-picker dropdown that lists OpenRouter's catalog dynamically — leave that to a follow-up
Multi-key rotation, fallback chains across providers, or cost-budget enforcement
First-party telemetry on cost or token usage
Migrating users already on anthropic to the new default — the default change only affects fresh installs / resetSettings(); existing users keep their configured provider via the version-1 settings round-trip
Updating TDD.md / ROADMAP.md status from 🟡/🟢; the release commit can do that
lib/providers/ollama.ts — pattern for validate() reporting and JSON-schema-based structured output
lib/providers/base.ts — finalizeResult and parseWithRepair are the contract; do not bypass them
lib/providers/http.ts — use requestJson for the actual fetch; do not call fetch directly. Pass providerLabel: 'OpenAI-compatible'. Leave networkFailureKind defaulted (network), not cors
lib/analysis/schema.ts — RESPONSE_JSON_SCHEMA is the JSON Schema mirror that goes into response_format.json_schema.schema; do not duplicate it
lib/errors.ts (ProviderError) — every failure mode must end in a typed ProviderError; no raw Error thrown across the boundary
lib/analysis/prompt.ts (buildPrompt) — gives { system, user }; send system as a system message and user as the user message
Dependencies:
No new packages
For the in-conversation test against OpenRouter, the developer running the integration smoke needs an OPENROUTER_API_KEY in their .env.local (or entered in the options page); this is not a CI dependency
Happy path: response_format: json_schema succeeds → mapped AnalysisResult with usage and cost
Fallback path: first call returns 400 with body containing json_schema → adapter retries with response_format: json_object → success
Auth path: 401 → ProviderError with kind: 'auth', retryable: false
Rate-limit path: 429 → ProviderError with kind: 'rate_limit', retryable: true
Schema-invalid response → one repair call happens, meta.schemaRepaired is true
Bearer token, HTTP-Referer, and X-Title are present in the captured RequestInit
Use the same injectable HttpDeps pattern (fetchImpl: vi.fn(...)) from the Anthropic and Ollama tests — no real network in unit tests
Add a focused test in lib/storage/secrets.test.ts (or wherever the existing secrets tests live; create if absent) that round-trips an openai_compat secret through applyPersistencePreference
Hard invariants to respect:
No new always-on host permissions; OpenRouter must go in optional_host_permissions and be requested at runtime via requestProviderPermission (already implemented for openai_compat in lib/permissions.ts)
The API key is read only by the background path via getSecret('openai_compat') and never returned to the UI; hasSecret('openai_compat') is what the options page renders against
Page text is hostile input — buildPrompt already delimits and escapes it, do not concatenate raw segment text into the system message
The response schema is validated; one repair attempt; never display a raw HTTP body to the user
Complexity
complexity:medium — Multiple files, but two sibling adapters (Anthropic, Ollama) already establish the pattern; the only genuinely new wrinkle is the json_object fallback path
Agent Readiness
Scope is bounded (can be done in one PR)
Success criteria are measurable
Context explains the "why" (cost; validates AD-4)
Patterns are linked, not assumed (anthropic.ts, ollama.ts, base.ts, http.ts)
No external blockers (OpenRouter is publicly available; the adapter's tests are pure unit tests)
Summary
Implement the
OpenAICompatProvideradapter (TDD Story 7) and switch the project's default backend to OpenRouter + GLM 5.2, which is cheaper than Anthropic Sonnet for this workload.Context
The provider layer (
lib/providers) is designed around three real adapters behind one interface — Anthropic, Ollama, and an OpenAI-compatible adapter that doubles as a gateway (TDD §3 AD-4). Anthropic and Ollama are live; the OpenAI-compat slot is a typed stub that throws inlib/providers/index.ts:34. Settings (openai_compatinlib/storage/settings.ts:39), runtime permissions (lib/permissions.ts:17), the readiness check (lib/providers/readiness.ts:15), the manifest'soptional_host_permissions, and UI provider labels already reserve the slot — the missing piece is the adapter itself and the options-UI surface for it.The trigger for doing this now: GLM 5.2 is available via OpenRouter at a meaningfully lower price than Claude Sonnet 4.6 (the current
anthropicdefault inDEFAULT_SETTINGS) for prompts of Slopwatch's shape. Routing through OpenRouter via the OpenAI-compatible adapter lets us flip the default backend without adding a fourth provider type, and it validates the "OpenAI-compat as gateway" architectural decision.The adapter must mirror the shape of the existing Anthropic and Ollama providers: validated structured output, one repair attempt, normalized errors, no raw HTTP bodies leaking to the UI, secrets that never round-trip through the renderer.
Acceptance Criteria
lib/providers/openai-compat.tsexportsOpenAICompatProviderimplementingAnalysisProviderfromlib/types.tslib/providers/index.ts(replacing the currentProviderErrorthrow foropenai_compat)analyze()POSTs to{baseUrl}/chat/completionswithAuthorization: Bearer <key>,content-type: application/json, and the OpenRouter attribution headers (HTTP-Referer: <extension origin>,X-Title: Slopwatch) — the attribution headers are harmless when the user points at vanilla OpenAI or another gatewayresponse_format: { type: "json_schema", json_schema: { name: "slopwatch_analysis", strict: true, schema: RESPONSE_JSON_SCHEMA } }. On a 400 response whose body indicates the gateway does not supportjson_schema(string-match onjson_schema,response_format, orunsupported), retry once withresponse_format: { type: "json_object" }. On any other 4xx, propagate the typedProviderError— do NOT silently fall backjson_objectfallback path are unit-tested inlib/providers/openai-compat.test.ts(mirror the shape ofanthropic.test.ts)parseWithRepairfromlib/providers/base.ts(one repair attempt on the assistant text before failing withbad_response)validate()does a 1-token round trip and returns{ ok, detail }— same contract asAnthropicProvider.validate()usageis populated from the response'susage.prompt_tokensandusage.completion_tokenswhen present. Cost estimation is best-effort: a smallPRICINGtable covering at least the configured GLM 5.2 slug andgpt-4o-mini, returningundefinedfor unknown models (no crash)DEFAULT_SETTINGS.providers.openai_compatis updated to{ model: '<openrouter glm-5.2 slug>', baseUrl: 'https://openrouter.ai/api/v1' }. Verify the exact slug againsthttps://openrouter.ai/api/v1/modelsat implementation time; if GLM 5.2 is not yet listed under that name, use GLM 4.6's slug and leave a// TODO(slopwatch):to bumpDEFAULT_SETTINGS.activeProvideris changed from'anthropic'to'openai_compat'optional_host_permissionsinwxt.config.tsincludeshttps://openrouter.ai/*alongside the existinghttps://api.openai.com/*entryentrypoints/options/App.tsx) gains an OpenAI-compatible section — model field, base-URL field, API-key entry (write-only, masked "configured" state), and Test connection button — mirroring the Anthropic and Ollama sections. The provider must appear inPROVIDER_OPTIONSfor production buildsapplyPersistencePreferenceinlib/storage/secrets.tsalready iterates'openai_compat', so no change there; verify by adding a unit test that persists/clears anopenai_compatsecretpnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm build:firefoxare cleanScope
In scope:
OpenAICompatProvideradapter and its unit testslib/providers/index.tsoptional_host_permissionsaddition foropenrouter.aijson_objectfallback path triggered only by a schema-rejection 400docs/LEARNINGS.mdrecording the default-provider change (one paragraph: what changed, why GLM via OpenRouter, what to watch for in real-world results)Out of scope:
stream: true) — we use blocking JSONanthropicto the new default — the default change only affects fresh installs /resetSettings(); existing users keep their configured provider via the version-1 settings round-tripTDD.md/ROADMAP.mdstatus from 🟡/🟢; the release commit can do thatTechnical Notes
Key files to create or modify:
Patterns to follow:
lib/providers/anthropic.ts— closest sibling: header construction,requestJsonusage,parseWithRepair,finalizeResult, cost estimation table. Mirror its structurelib/providers/ollama.ts— pattern forvalidate()reporting and JSON-schema-based structured outputlib/providers/base.ts—finalizeResultandparseWithRepairare the contract; do not bypass themlib/providers/http.ts— userequestJsonfor the actual fetch; do not callfetchdirectly. PassproviderLabel: 'OpenAI-compatible'. LeavenetworkFailureKinddefaulted (network), notcorslib/analysis/schema.ts—RESPONSE_JSON_SCHEMAis the JSON Schema mirror that goes intoresponse_format.json_schema.schema; do not duplicate itlib/errors.ts(ProviderError) — every failure mode must end in a typedProviderError; no rawErrorthrown across the boundarylib/analysis/prompt.ts(buildPrompt) — gives{ system, user }; sendsystemas asystemmessage anduseras theusermessageDependencies:
OPENROUTER_API_KEYin their.env.local(or entered in the options page); this is not a CI dependencyTesting approach:
lib/providers/openai-compat.test.tsfollowslib/providers/anthropic.test.ts:response_format: json_schemasucceeds → mappedAnalysisResultwith usage and costjson_schema→ adapter retries withresponse_format: json_object→ successProviderErrorwithkind: 'auth',retryable: falseProviderErrorwithkind: 'rate_limit',retryable: truemeta.schemaRepairedistrueHTTP-Referer, andX-Titleare present in the capturedRequestInitHttpDepspattern (fetchImpl: vi.fn(...)) from the Anthropic and Ollama tests — no real network in unit testslib/storage/secrets.test.ts(or wherever the existing secrets tests live; create if absent) that round-trips anopenai_compatsecret throughapplyPersistencePreferenceHard invariants to respect:
optional_host_permissionsand be requested at runtime viarequestProviderPermission(already implemented foropenai_compatinlib/permissions.ts)getSecret('openai_compat')and never returned to the UI;hasSecret('openai_compat')is what the options page renders againstbuildPromptalready delimits and escapes it, do not concatenate raw segment text into the system messageComplexity
complexity:medium— Multiple files, but two sibling adapters (Anthropic, Ollama) already establish the pattern; the only genuinely new wrinkle is thejson_objectfallback pathAgent Readiness
anthropic.ts,ollama.ts,base.ts,http.ts)