Skip to content

Implement OpenAI-compatible provider (Story 7) and switch default to OpenRouter + GLM 5.2 #1

Description

@ericmann

Summary

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

Technical Notes

Key files to create or modify:

- lib/providers/openai-compat.ts                (create — adapter)
- lib/providers/openai-compat.test.ts           (create — unit tests)
- lib/providers/index.ts                        (modify — wire factory)
- lib/storage/settings.ts                       (modify — flip default + base URL)
- wxt.config.ts                                 (modify — add openrouter.ai to optional_host_permissions)
- entrypoints/options/App.tsx                   (modify — add OpenAI-compat section to PROVIDER_OPTIONS + form)
- docs/LEARNINGS.md                             (modify — record provider default change)

Patterns to follow:

  • lib/providers/anthropic.ts — closest sibling: header construction, requestJson usage, parseWithRepair, finalizeResult, cost estimation table. Mirror its structure
  • lib/providers/ollama.ts — pattern for validate() reporting and JSON-schema-based structured output
  • lib/providers/base.tsfinalizeResult 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.tsRESPONSE_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

Testing approach:

  • lib/providers/openai-compat.test.ts follows lib/providers/anthropic.test.ts:
    • 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)
  • Complexity is appropriate for agent execution

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-readyIssue is properly scoped for AI agent executioncomplexity:mediumMultiple files, follows established patternsenhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions