feat(ai): native Claude support on desktop (ADR-0016 Track A) - #300
Conversation
Four findings deferred from PR #297 to keep that PR's merge unblocked, addressed here first per explicit instruction before starting the Grok/Claude/Ollama plan execution: 1. GROK-PROVIDER-INTEGRATION-PLAN.md: the addendum was numbered "## 7" but its own subsections said "8.1"-"8.4", and a "§§1-7 above" self-reference included the addendum itself. Renumbered to 7.1-7.4, fixed both self-references, and fixed a second pre-existing ambiguous "§5" cross-reference (meant "item 5 within Phase 1's own list", not top-level section 5) while in the area. 2. tests/unit/listenerMiddleware.test.ts: added the required QNBS-v3 comment next to the new featureFlagsActions import. 3. Added the required QNBS-v3 comment before the new "local-first shadow sync" describe block. 4. Rewrote the local-first sync test to assert the actual selector-to-binding boundary directly instead of inferring success from an absence of logger calls in an unmocked-dependency, environment-dependent setup. Added real mocks for services/localFirst/{projectDoc,docBinding,docPersistence} and services/storage/storageEncryptionService so the test can assert the exact object selectProjectData(state) returns is what reaches ProjectDocBinding's constructor and syncFromProject/verify. Hit two real bugs while building this: (a) an arrow-function mock implementation can never be used as a constructor -- switched to a real class, which Biome's useArrowFunction rule doesn't flag either (a function-expression fix would have been immediately reverted by the same linter that was satisfied by removing the arrow function in the first place); (b) vi.fn().mockImplementation() can't type a class constructor against its generic (...args) => any signature -- mocked the class directly instead of wrapping it in vi.fn().
Re-checked the plan's own findings against current main before starting
implementation (Phase 0's explicit purpose), and found the original
draft's Claude analysis was incomplete in a way that changes the whole
approach:
- AiProviderCard.tsx's primary provider dropdown already has an
'anthropic' entry with a dedicated warning block -- missed originally
because the investigation searched for the literal string "claude",
never "anthropic" (the actual identifier), when checking that file.
Its existing i18n copy already half-promises desktop support
("...or use the Tauri desktop app for Anthropic calls").
- streamAnthropic() throws unconditionally on EVERY platform, including
desktop -- but CORS is a browser-only restriction. It never even
checks isTauriRuntime() before throwing. Tauri's native HTTP plugin
(@tauri-apps/plugin-http, services/localServerHttp.ts) already solves
exactly this class of problem for Ollama/LM Studio/vLLM (ADR-0012);
the same escape hatch works for any HTTPS endpoint, including
api.anthropic.com, since native networking isn't subject to browser
CORS at all.
Restructured Phase 2 into two independent tracks: Track A (desktop) is
a narrow bug fix reusing ADR-0012's established pattern, no new
infrastructure, ships first. Track B (web/PWA) is the actual new
architecture -- the serverless proxy -- and is now correctly scoped to
only the three web deploy targets (desktop no longer needs it at all).
Updated the Executive Summary, both Definition of Done checklists, and
all internal cross-references accordingly.
Formalizes GROK-PROVIDER-INTEGRATION-PLAN.md's Phase 0 decision record: Grok is a pure UI-wiring fix (backend already works); Claude splits into Track A (desktop, native-HTTP via the ADR-0012 pattern, ships first, no new infrastructure) and Track B (web/PWA, this app's first serverless backend dependency, ships second). Also documents why the Ollama-in-PWA follow-up (Issue #266) is a separate decision, not a Track-B variant -- a hosted proxy can't reach a user's own localhost.
Grok (xAI, Cloud 4) had a real, working backend (aiProviderService.ts's streamGrok(), key storage, a live /v1/models connection test) but was never selectable as a primary provider -- reachable only as a hybrid- fallback-chain option. This closes that UI/wiring gap; no new backend work needed. - AiProviderCard.tsx: added Grok to the primary provider dropdown with its own API-key input + model selector (grok-3 / grok-3-mini), reusing the existing storageService key-storage plumbing and the already-working testAIConnection() grok case for Test Connection. Also fixed the whole provider array's i18n while touching it -- gemini/openai/ollama/anthropic labels were hardcoded literals too, not just Grok's gap. - providerFactory.ts: providerToKind() gets a distinct 'grok' kind (not folded into 'openaiCompatible') -- Grok needs a fixed baseURL (https://api.x.ai/v1) and a real stored key, unlike the Ollama default that also maps to 'openaiCompatible'; folding it in would have silently sent Grok requests through Ollama's localhost default. - worldScriptCompletionFetch.ts: resolveModelConfig() branches on the new 'grok' kind, bringing Grok into the newer Writer-streaming path (useWorldScriptAI), not just legacy thunks. - i18n: added settings.ai.grokKey + settings.ai.provider.{gemini, openai,ollama,anthropic,grok} across all 19 locales -- hand- translated for the 5 production locales (de/en/es/fr/it), EN fallback seeded for the other 14 pending the established bulk- translate workflow. Bundles rebuilt; README's key-count badge/table updated 2849 -> 2855. - Tests: providerFactory (grok maps to 'grok', not 'unsupported'), worldScriptCompletionFetch (grok success path + missing-key 401), AiProviderCard (renders key input + model selector, saves via storageService.saveApiKey('grok', ...)). No CSP change needed: https://api.x.ai is already in src-tauri/tauri.conf.json, and the web CSP's https: scheme-source (ADR-0004) already covers it. No new feature flag -- confirmed via `pnpm exec tsx scripts/audit-feature-parity.ts` (22 flags, 0 drifts). The many locales/*/{characters,common,dashboard,desktop,export, outline,worlds,writer}.json changes in this commit are incidental -- running the i18n tooling's --fix pass re-sorted keys in files it touched along the way. No content was added, removed, or changed in any of those files, only key order.
Claude/Anthropic's streamAnthropic() threw unconditionally on every platform, including desktop -- but CORS is a browser-only restriction. Tauri's native HTTP plugin (localServerFetch, ADR-0012) already exists in this codebase to bypass exactly this class of problem for Ollama; the same escape hatch works for any HTTPS endpoint, since native networking isn't subject to browser CORS at all. - aiProviderService.ts: streamAnthropic() branches on isTauriRuntime() before throwing -- desktop calls https://api.anthropic.com/v1/messages directly via localServerFetch (Messages API format: x-api-key + anthropic-version headers). generateTextSingleProvider()'s anthropic case now delegates to the fixed streamAnthropic instead of its own separate unconditional throw. testAIConnection()'s anthropic case gets the same branch, using a minimal (max_tokens: 1) real request as the connectivity check since Anthropic has no public /v1/models endpoint to probe. Image generation is deliberately left throwing -- Anthropic's API doesn't offer that endpoint at all, so this isn't a CORS bug to fix. - src-tauri/tauri.conf.json: added https://api.anthropic.com to the CSP connect-src (mirrors the existing Grok entry). - AiProviderCard.tsx: the existing 'anthropic' warning-only block is now desktop-conditional -- desktop renders a real API-key input + model selector (Opus/Sonnet/Haiku 4.x) exactly like every other cloud provider; web/PWA keeps the warning, with copy updated to say desktop now genuinely works rather than "might" (Track B's proxy note stays "coming soon", since that part isn't built yet). - i18n: added settings.ai.anthropicKey; revised anthropicCorsNote/ anthropicHint wording across the 5 production locales to reflect desktop actually working now; EN fallback seeded for the other 14. 2855 -> 2856 keys; README badge/table updated. - Tests: aiProviderService (testAIConnection + streamText desktop success/failure/no-key paths, web-path unchanged), AiProviderCard (desktop shows key input, web shows warning, key save wired to storageService.saveApiKey('anthropic', ...)). Track B (the web/PWA serverless proxy) is unaffected by this commit -- still not built, still the only path for browser-tab Claude.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 44 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAnthropic now supports stored API keys, desktop-only native HTTP streaming and connection testing, and runtime-specific settings UI. Anthropic localization entries and guidance were updated across locales, Tauri CSP endpoints were expanded, and documented i18n totals increased to 2856. ChangesAnthropic runtime and request flow
Anthropic settings UI
Localization and documentation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant SettingsUI
participant aiProviderService
participant TauriHTTP
participant Anthropic
SettingsUI->>aiProviderService: test connection or request text
aiProviderService->>TauriHTTP: localServerFetch
TauriHTTP->>Anthropic: POST /v1/messages
Anthropic-->>TauriHTTP: response
TauriHTTP-->>aiProviderService: HTTP result
aiProviderService-->>SettingsUI: status or streamed text
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
🏁 CodeAnt Quality Gate ResultsCommit: ✅ Overall Status: PASSEDQuality Gate Details
|
Add missing single-line QNBS-v3 rationale comments (Grok key state, key-persistence handler, the openaiCompatible-reuse decision in worldScriptCompletionFetch.ts, two test fixtures/blocks) and collapse four multi-line QNBS-v3 comments to the required single line. Not addressed here (documented, not fixed): the ADR-0016 wording describing Claude desktop as immediately usable is accurate once the full stacked sequence merges (Track A is PR #300, already stacked next) and will read correctly at that point without further edits; the locale-translation findings for settings.ai.grokKey reflect this repo's established i18n tiering (5 production locales get hand translations, 14 beta locales intentionally carry an EN fallback per CLAUDE.md's own i18n section), not a defect; the vi.mock hoisting concern in listenerMiddleware.test.ts is a false positive verified against the actual passing suite -- ProjectDocBinding is consumed via a dynamic await import() inside test bodies, not a static top-level import, so by the time any mock factory runs the module has already evaluated top-to-bottom once.
|
@coderabbitai review |
✅ Action performedReview finished.
|
* fix: address deferred CodeRabbit findings from PR #297 Four findings deferred from PR #297 to keep that PR's merge unblocked, addressed here first per explicit instruction before starting the Grok/Claude/Ollama plan execution: 1. GROK-PROVIDER-INTEGRATION-PLAN.md: the addendum was numbered "## 7" but its own subsections said "8.1"-"8.4", and a "§§1-7 above" self-reference included the addendum itself. Renumbered to 7.1-7.4, fixed both self-references, and fixed a second pre-existing ambiguous "§5" cross-reference (meant "item 5 within Phase 1's own list", not top-level section 5) while in the area. 2. tests/unit/listenerMiddleware.test.ts: added the required QNBS-v3 comment next to the new featureFlagsActions import. 3. Added the required QNBS-v3 comment before the new "local-first shadow sync" describe block. 4. Rewrote the local-first sync test to assert the actual selector-to-binding boundary directly instead of inferring success from an absence of logger calls in an unmocked-dependency, environment-dependent setup. Added real mocks for services/localFirst/{projectDoc,docBinding,docPersistence} and services/storage/storageEncryptionService so the test can assert the exact object selectProjectData(state) returns is what reaches ProjectDocBinding's constructor and syncFromProject/verify. Hit two real bugs while building this: (a) an arrow-function mock implementation can never be used as a constructor -- switched to a real class, which Biome's useArrowFunction rule doesn't flag either (a function-expression fix would have been immediately reverted by the same linter that was satisfied by removing the arrow function in the first place); (b) vi.fn().mockImplementation() can't type a class constructor against its generic (...args) => any signature -- mocked the class directly instead of wrapping it in vi.fn(). * docs: Phase 0 re-verification finds Claude split -- two tracks, not one Re-checked the plan's own findings against current main before starting implementation (Phase 0's explicit purpose), and found the original draft's Claude analysis was incomplete in a way that changes the whole approach: - AiProviderCard.tsx's primary provider dropdown already has an 'anthropic' entry with a dedicated warning block -- missed originally because the investigation searched for the literal string "claude", never "anthropic" (the actual identifier), when checking that file. Its existing i18n copy already half-promises desktop support ("...or use the Tauri desktop app for Anthropic calls"). - streamAnthropic() throws unconditionally on EVERY platform, including desktop -- but CORS is a browser-only restriction. It never even checks isTauriRuntime() before throwing. Tauri's native HTTP plugin (@tauri-apps/plugin-http, services/localServerHttp.ts) already solves exactly this class of problem for Ollama/LM Studio/vLLM (ADR-0012); the same escape hatch works for any HTTPS endpoint, including api.anthropic.com, since native networking isn't subject to browser CORS at all. Restructured Phase 2 into two independent tracks: Track A (desktop) is a narrow bug fix reusing ADR-0012's established pattern, no new infrastructure, ships first. Track B (web/PWA) is the actual new architecture -- the serverless proxy -- and is now correctly scoped to only the three web deploy targets (desktop no longer needs it at all). Updated the Executive Summary, both Definition of Done checklists, and all internal cross-references accordingly. * docs(adr): add ADR-0016 for native Grok provider + split Claude fix Formalizes GROK-PROVIDER-INTEGRATION-PLAN.md's Phase 0 decision record: Grok is a pure UI-wiring fix (backend already works); Claude splits into Track A (desktop, native-HTTP via the ADR-0012 pattern, ships first, no new infrastructure) and Track B (web/PWA, this app's first serverless backend dependency, ships second). Also documents why the Ollama-in-PWA follow-up (Issue #266) is a separate decision, not a Track-B variant -- a hosted proxy can't reach a user's own localhost. * feat(ai): Phase 1 -- native Grok provider wiring Grok (xAI, Cloud 4) had a real, working backend (aiProviderService.ts's streamGrok(), key storage, a live /v1/models connection test) but was never selectable as a primary provider -- reachable only as a hybrid- fallback-chain option. This closes that UI/wiring gap; no new backend work needed. - AiProviderCard.tsx: added Grok to the primary provider dropdown with its own API-key input + model selector (grok-3 / grok-3-mini), reusing the existing storageService key-storage plumbing and the already-working testAIConnection() grok case for Test Connection. Also fixed the whole provider array's i18n while touching it -- gemini/openai/ollama/anthropic labels were hardcoded literals too, not just Grok's gap. - providerFactory.ts: providerToKind() gets a distinct 'grok' kind (not folded into 'openaiCompatible') -- Grok needs a fixed baseURL (https://api.x.ai/v1) and a real stored key, unlike the Ollama default that also maps to 'openaiCompatible'; folding it in would have silently sent Grok requests through Ollama's localhost default. - worldScriptCompletionFetch.ts: resolveModelConfig() branches on the new 'grok' kind, bringing Grok into the newer Writer-streaming path (useWorldScriptAI), not just legacy thunks. - i18n: added settings.ai.grokKey + settings.ai.provider.{gemini, openai,ollama,anthropic,grok} across all 19 locales -- hand- translated for the 5 production locales (de/en/es/fr/it), EN fallback seeded for the other 14 pending the established bulk- translate workflow. Bundles rebuilt; README's key-count badge/table updated 2849 -> 2855. - Tests: providerFactory (grok maps to 'grok', not 'unsupported'), worldScriptCompletionFetch (grok success path + missing-key 401), AiProviderCard (renders key input + model selector, saves via storageService.saveApiKey('grok', ...)). No CSP change needed: https://api.x.ai is already in src-tauri/tauri.conf.json, and the web CSP's https: scheme-source (ADR-0004) already covers it. No new feature flag -- confirmed via `pnpm exec tsx scripts/audit-feature-parity.ts` (22 flags, 0 drifts). The many locales/*/{characters,common,dashboard,desktop,export, outline,worlds,writer}.json changes in this commit are incidental -- running the i18n tooling's --fix pass re-sorted keys in files it touched along the way. No content was added, removed, or changed in any of those files, only key order. * docs: address CodeRabbit quick-win findings on PR #299 Add missing single-line QNBS-v3 rationale comments (Grok key state, key-persistence handler, the openaiCompatible-reuse decision in worldScriptCompletionFetch.ts, two test fixtures/blocks) and collapse four multi-line QNBS-v3 comments to the required single line. Not addressed here (documented, not fixed): the ADR-0016 wording describing Claude desktop as immediately usable is accurate once the full stacked sequence merges (Track A is PR #300, already stacked next) and will read correctly at that point without further edits; the locale-translation findings for settings.ai.grokKey reflect this repo's established i18n tiering (5 production locales get hand translations, 14 beta locales intentionally carry an EN fallback per CLAUDE.md's own i18n section), not a defect; the vi.mock hoisting concern in listenerMiddleware.test.ts is a false positive verified against the actual passing suite -- ProjectDocBinding is consumed via a dynamic await import() inside test bodies, not a static top-level import, so by the time any mock factory runs the module has already evaluated top-to-bottom once.
…esktop # Conflicts: # README.md # components/settings/AiProviderCard.tsx # docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md # tests/unit/settings/AiProviderCard.test.tsx
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
components/settings/AiProviderCard.tsx (1)
98-109: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winSave failures are unhandled. If
saveApiKey/clearApiKeyrejects, the promise rejection escapes the handler and the user gets no feedback (the spinner just stops). Mirrors the existing Grok/OpenAI handlers, so a shared helper would remove the triplication too.♻️ Handle the failure
const handleSaveAnthropicKey = useCallback(async () => { setIsSavingAnthropicKey(true); try { if (anthropicKey.trim()) { await storageService.saveApiKey('anthropic', anthropicKey.trim()); } else { await storageService.clearApiKey('anthropic'); } + } catch (e) { + log.error('AiProviderCard: failed to persist Anthropic key', { + message: e instanceof Error ? e.message : String(e), + }); + setTestError(t('settings.ai.keySaveFailed')); } finally { setIsSavingAnthropicKey(false); } - }, [anthropicKey]); + }, [anthropicKey, t]);As per coding guidelines, "Async operations must use
try/catchor a Result type; silent swallowing is prohibited except for documented aborts," and technical detail must go throughservices/logger.ts.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@components/settings/AiProviderCard.tsx` around lines 98 - 109, Update handleSaveAnthropicKey to catch saveApiKey/clearApiKey failures, report technical details through the existing logger service, and provide user-facing failure feedback consistent with the Grok/OpenAI handlers. Preserve the loading-state cleanup in finally, and reuse a shared helper if those handlers duplicate the same save/error logic.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@services/aiProviderService.ts`:
- Around line 875-887: Bound both new localServerFetch Anthropic requests: in
services/aiProviderService.ts lines 875-887, add an 8000ms AbortSignal.timeout
to the ping request and map its abort failure to the existing kind: 'timeout';
in services/aiProviderService.ts lines 260-273, compose the same timeout with
opts.signal rather than passing opts.signal ?? null, preserving caller
cancellation while preventing unbounded requests.
- Around line 275-277: Update the response parsing near the Anthropic request
handling to collect every content block whose type is "text" and concatenate
their text values in order before invoking callbacks.onChunk. Replace the
single-block find logic while preserving the empty-string fallback when no text
blocks exist.
---
Nitpick comments:
In `@components/settings/AiProviderCard.tsx`:
- Around line 98-109: Update handleSaveAnthropicKey to catch
saveApiKey/clearApiKey failures, report technical details through the existing
logger service, and provide user-facing failure feedback consistent with the
Grok/OpenAI handlers. Preserve the loading-state cleanup in finally, and reuse a
shared helper if those handlers duplicate the same save/error logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4d2a9399-678c-4ce4-93e0-a40582cdbf23
📒 Files selected for processing (45)
README.mdcomponents/settings/AiProviderCard.tsxdocs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.mdlocales/ar/settings.jsonlocales/de/settings.jsonlocales/el/settings.jsonlocales/en/settings.jsonlocales/es/settings.jsonlocales/eu/settings.jsonlocales/fa/settings.jsonlocales/fi/settings.jsonlocales/fr/settings.jsonlocales/he/settings.jsonlocales/hu/settings.jsonlocales/is/settings.jsonlocales/it/settings.jsonlocales/ja/settings.jsonlocales/ko/settings.jsonlocales/pt/settings.jsonlocales/ru/settings.jsonlocales/sv/settings.jsonlocales/zh/settings.jsonpublic/locales/ar/bundle.jsonpublic/locales/de/bundle.jsonpublic/locales/el/bundle.jsonpublic/locales/en/bundle.jsonpublic/locales/es/bundle.jsonpublic/locales/eu/bundle.jsonpublic/locales/fa/bundle.jsonpublic/locales/fi/bundle.jsonpublic/locales/fr/bundle.jsonpublic/locales/he/bundle.jsonpublic/locales/hu/bundle.jsonpublic/locales/is/bundle.jsonpublic/locales/it/bundle.jsonpublic/locales/ja/bundle.jsonpublic/locales/ko/bundle.jsonpublic/locales/pt/bundle.jsonpublic/locales/ru/bundle.jsonpublic/locales/sv/bundle.jsonpublic/locales/zh/bundle.jsonservices/aiProviderService.tssrc-tauri/tauri.conf.jsontests/unit/aiProviderService.test.tstests/unit/settings/AiProviderCard.test.tsx
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Two Major CodeRabbit findings on PR #300, both real: - streamAnthropic() used content?.find(c => c.type === 'text') -- Claude can return multiple content blocks (e.g. a 'thinking' block plus several 'text' blocks on extended-thinking models), and find() silently dropped everything after the first match. Now filters and concatenates every text block in order. - testAIConnection's anthropic ping had no timeout, unlike every sibling connectivity check (testOllamaConnection: timeoutMs 5000; openai/grok/ gemini: AbortSignal.timeout(8000)) -- a stalled native HTTP call could hang the Settings test spinner indefinitely. Added timeoutMs: 8000. Deliberately NOT applied to streamAnthropic's own outbound call (the second location CodeRabbit's finding named): real generation calls across this file intentionally stay unbounded by a fixed timeout, using only the caller's own AbortSignal for cancellation -- Ollama's real generation call (streamOllama) follows the same pattern, only its connectivity check gets timeoutMs. Special-casing Anthropic's generation call would be the inconsistent choice, not the fix.
|
@coderabbitai review |
✅ Action performedReview finished.
|
* fix: address deferred CodeRabbit findings from PR #297 Four findings deferred from PR #297 to keep that PR's merge unblocked, addressed here first per explicit instruction before starting the Grok/Claude/Ollama plan execution: 1. GROK-PROVIDER-INTEGRATION-PLAN.md: the addendum was numbered "## 7" but its own subsections said "8.1"-"8.4", and a "§§1-7 above" self-reference included the addendum itself. Renumbered to 7.1-7.4, fixed both self-references, and fixed a second pre-existing ambiguous "§5" cross-reference (meant "item 5 within Phase 1's own list", not top-level section 5) while in the area. 2. tests/unit/listenerMiddleware.test.ts: added the required QNBS-v3 comment next to the new featureFlagsActions import. 3. Added the required QNBS-v3 comment before the new "local-first shadow sync" describe block. 4. Rewrote the local-first sync test to assert the actual selector-to-binding boundary directly instead of inferring success from an absence of logger calls in an unmocked-dependency, environment-dependent setup. Added real mocks for services/localFirst/{projectDoc,docBinding,docPersistence} and services/storage/storageEncryptionService so the test can assert the exact object selectProjectData(state) returns is what reaches ProjectDocBinding's constructor and syncFromProject/verify. Hit two real bugs while building this: (a) an arrow-function mock implementation can never be used as a constructor -- switched to a real class, which Biome's useArrowFunction rule doesn't flag either (a function-expression fix would have been immediately reverted by the same linter that was satisfied by removing the arrow function in the first place); (b) vi.fn().mockImplementation() can't type a class constructor against its generic (...args) => any signature -- mocked the class directly instead of wrapping it in vi.fn(). * docs: Phase 0 re-verification finds Claude split -- two tracks, not one Re-checked the plan's own findings against current main before starting implementation (Phase 0's explicit purpose), and found the original draft's Claude analysis was incomplete in a way that changes the whole approach: - AiProviderCard.tsx's primary provider dropdown already has an 'anthropic' entry with a dedicated warning block -- missed originally because the investigation searched for the literal string "claude", never "anthropic" (the actual identifier), when checking that file. Its existing i18n copy already half-promises desktop support ("...or use the Tauri desktop app for Anthropic calls"). - streamAnthropic() throws unconditionally on EVERY platform, including desktop -- but CORS is a browser-only restriction. It never even checks isTauriRuntime() before throwing. Tauri's native HTTP plugin (@tauri-apps/plugin-http, services/localServerHttp.ts) already solves exactly this class of problem for Ollama/LM Studio/vLLM (ADR-0012); the same escape hatch works for any HTTPS endpoint, including api.anthropic.com, since native networking isn't subject to browser CORS at all. Restructured Phase 2 into two independent tracks: Track A (desktop) is a narrow bug fix reusing ADR-0012's established pattern, no new infrastructure, ships first. Track B (web/PWA) is the actual new architecture -- the serverless proxy -- and is now correctly scoped to only the three web deploy targets (desktop no longer needs it at all). Updated the Executive Summary, both Definition of Done checklists, and all internal cross-references accordingly. * docs(adr): add ADR-0016 for native Grok provider + split Claude fix Formalizes GROK-PROVIDER-INTEGRATION-PLAN.md's Phase 0 decision record: Grok is a pure UI-wiring fix (backend already works); Claude splits into Track A (desktop, native-HTTP via the ADR-0012 pattern, ships first, no new infrastructure) and Track B (web/PWA, this app's first serverless backend dependency, ships second). Also documents why the Ollama-in-PWA follow-up (Issue #266) is a separate decision, not a Track-B variant -- a hosted proxy can't reach a user's own localhost. * feat(ai): Phase 1 -- native Grok provider wiring Grok (xAI, Cloud 4) had a real, working backend (aiProviderService.ts's streamGrok(), key storage, a live /v1/models connection test) but was never selectable as a primary provider -- reachable only as a hybrid- fallback-chain option. This closes that UI/wiring gap; no new backend work needed. - AiProviderCard.tsx: added Grok to the primary provider dropdown with its own API-key input + model selector (grok-3 / grok-3-mini), reusing the existing storageService key-storage plumbing and the already-working testAIConnection() grok case for Test Connection. Also fixed the whole provider array's i18n while touching it -- gemini/openai/ollama/anthropic labels were hardcoded literals too, not just Grok's gap. - providerFactory.ts: providerToKind() gets a distinct 'grok' kind (not folded into 'openaiCompatible') -- Grok needs a fixed baseURL (https://api.x.ai/v1) and a real stored key, unlike the Ollama default that also maps to 'openaiCompatible'; folding it in would have silently sent Grok requests through Ollama's localhost default. - worldScriptCompletionFetch.ts: resolveModelConfig() branches on the new 'grok' kind, bringing Grok into the newer Writer-streaming path (useWorldScriptAI), not just legacy thunks. - i18n: added settings.ai.grokKey + settings.ai.provider.{gemini, openai,ollama,anthropic,grok} across all 19 locales -- hand- translated for the 5 production locales (de/en/es/fr/it), EN fallback seeded for the other 14 pending the established bulk- translate workflow. Bundles rebuilt; README's key-count badge/table updated 2849 -> 2855. - Tests: providerFactory (grok maps to 'grok', not 'unsupported'), worldScriptCompletionFetch (grok success path + missing-key 401), AiProviderCard (renders key input + model selector, saves via storageService.saveApiKey('grok', ...)). No CSP change needed: https://api.x.ai is already in src-tauri/tauri.conf.json, and the web CSP's https: scheme-source (ADR-0004) already covers it. No new feature flag -- confirmed via `pnpm exec tsx scripts/audit-feature-parity.ts` (22 flags, 0 drifts). The many locales/*/{characters,common,dashboard,desktop,export, outline,worlds,writer}.json changes in this commit are incidental -- running the i18n tooling's --fix pass re-sorted keys in files it touched along the way. No content was added, removed, or changed in any of those files, only key order. * feat(ai): Phase 2 Track A -- native Claude support on desktop Claude/Anthropic's streamAnthropic() threw unconditionally on every platform, including desktop -- but CORS is a browser-only restriction. Tauri's native HTTP plugin (localServerFetch, ADR-0012) already exists in this codebase to bypass exactly this class of problem for Ollama; the same escape hatch works for any HTTPS endpoint, since native networking isn't subject to browser CORS at all. - aiProviderService.ts: streamAnthropic() branches on isTauriRuntime() before throwing -- desktop calls https://api.anthropic.com/v1/messages directly via localServerFetch (Messages API format: x-api-key + anthropic-version headers). generateTextSingleProvider()'s anthropic case now delegates to the fixed streamAnthropic instead of its own separate unconditional throw. testAIConnection()'s anthropic case gets the same branch, using a minimal (max_tokens: 1) real request as the connectivity check since Anthropic has no public /v1/models endpoint to probe. Image generation is deliberately left throwing -- Anthropic's API doesn't offer that endpoint at all, so this isn't a CORS bug to fix. - src-tauri/tauri.conf.json: added https://api.anthropic.com to the CSP connect-src (mirrors the existing Grok entry). - AiProviderCard.tsx: the existing 'anthropic' warning-only block is now desktop-conditional -- desktop renders a real API-key input + model selector (Opus/Sonnet/Haiku 4.x) exactly like every other cloud provider; web/PWA keeps the warning, with copy updated to say desktop now genuinely works rather than "might" (Track B's proxy note stays "coming soon", since that part isn't built yet). - i18n: added settings.ai.anthropicKey; revised anthropicCorsNote/ anthropicHint wording across the 5 production locales to reflect desktop actually working now; EN fallback seeded for the other 14. 2855 -> 2856 keys; README badge/table updated. - Tests: aiProviderService (testAIConnection + streamText desktop success/failure/no-key paths, web-path unchanged), AiProviderCard (desktop shows key input, web shows warning, key save wired to storageService.saveApiKey('anthropic', ...)). Track B (the web/PWA serverless proxy) is unaffected by this commit -- still not built, still the only path for browser-tab Claude. * feat(ai): Phase 2 Track B -- Claude serverless proxy for web/PWA Track A (previous commit) fixed Claude on desktop. This closes the other half: browser tabs have no native-HTTP escape hatch from CORS, so Anthropic requests from Vercel/Cloudflare Pages deployments now relay through this app's own stateless serverless proxy -- its first backend dependency ever (previously a purely static SPA + a desktop bundle with no server of its own). - api/_shared/claudeProxyCore.ts: platform-agnostic relay core (Web Request/Response only, no @vercel/node or @cloudflare/workers-types dependency). Forwards { apiKey, model, messages, maxTokens? } to Anthropic's Messages API and returns the response unmodified. Mandatory abuse controls per ADR-0016 (the endpoint is public and unauthenticated by construction -- CWE-400 surface): Zod schema validation, a 256 KiB body-size cap checked against both the Content-Length header and the actual body (defeats a spoofed header), a same-origin check (Origin must match the deployment's own host), a best-effort per-client-IP rate limit (20 req/60s, in-memory -- explicitly not distributed, see code comment), and a 20s outbound timeout to Anthropic. Never logs the key, prompt, or response on any path -- asserted directly in tests via console spies across every branch. - api/claude-proxy.ts: Vercel Edge Function entry point (thin wrapper). - functions/api/claude-proxy.ts: Cloudflare Pages Function equivalent, at the matching /api/claude-proxy route (Cloudflare has no automatic "/api" prefix the way some platforms do -- the path has to earn it). - services/deployTarget.ts: isServerlessProxyCapable() -- reuses import.meta.env.BASE_URL (already this codebase's build-time GitHub-Pages-vs-edge marker, see config/resolveViteBase.ts) to detect whether the current web build can host the proxy at all. GitHub Pages is static-only and never can. - services/aiProviderService.ts: streamAnthropic() now has three branches -- desktop (Track A, unchanged), proxy-capable web (fetches /api/claude-proxy), and GitHub Pages (throws immediately, before ever checking for a key, since no key would help). testAIConnection's anthropic case gets the same three-way split. Retired the 'backendProxyRequired' TestConnectionErrorKind (nothing can produce it anymore) in favor of 'proxyUnavailableStaticHost'. - AiProviderCard.tsx: extracted the whole anthropic UI block into components/settings/AnthropicProviderFields.tsx -- both to keep AiProviderCard under the Biome cognitive-complexity gate (52 > 50 before the split) and under CLAUDE.md's 700-line file-size target. Desktop and proxy-capable web now render the same real key + model UI every other cloud provider gets; only GitHub Pages keeps the warning block, with copy rewritten from a generic CORS note to the actual "no proxy host" reason. - i18n: settings.ai.anthropicProxyNote (new); anthropicCorsNote/ anthropicHint/corsRestriction copy rewritten (desktop and Vercel/CF no longer say "coming soon" -- they work); testError. backendProxyRequired removed, testError.proxyUnavailableStaticHost added. Hand-translated for the 5 production locales; EN fallback seeded for the other 14. 2856 -> 2857 keys; README badge/table/count updated in all 4 places. - docs/SECURITY-THREAT-MODEL.md: new "Claude serverless proxy trust-model change" section, an Information Disclosure row, a Denial of Service row (the abuse-control list), a Mitigation Mapping row, and a checklist item -- this is the first provider whose BYOK key transits infrastructure WorldScript runs, and that's stated plainly rather than folded into the general BYOK framing. - README.md: privacy bullet and the encryption section both carry the same caveat; the Cloud 3 provider-stack row lists the actual model ids (Opus 4.7/Sonnet 4.6/Haiku 4.5, not the stale "Claude 3.5 Sonnet") and where Claude does/doesn't work. - docs/adr/0016: documents why providerFactory.ts's Vercel-AI-SDK layer still returns 'unsupported' for anthropic (would need the @ai-sdk/anthropic package -- Anthropic's Messages API isn't OpenAI-Chat-Completions-shaped like Grok/Ollama are, so that's a new runtime dependency out of scope here) and why no dedicated E2E spec was added (no other cloud provider -- including Grok -- has one in this repo; the RTL/unit level is where this class of Settings-form coverage already lives). Tests: 14 new (claudeProxyCore: schema/size/origin/rate-limit/timeout/ relay-fidelity/no-logging), 3 new (Vercel + Cloudflare entry-point delegation), 3 new (deployTarget branch coverage), aiProviderService's Anthropic describe block rewritten for the three-way split, AiProviderCard's anthropic describe block rewritten with a new proxy-capable-web case. 126 tests green across the full Track B surface; lint, typecheck, i18n:check, docs:check, and parity:check (22 flags, 0 drifts -- no new flag needed) all clean. * docs: address CodeRabbit quick-win findings on PR #299 Add missing single-line QNBS-v3 rationale comments (Grok key state, key-persistence handler, the openaiCompatible-reuse decision in worldScriptCompletionFetch.ts, two test fixtures/blocks) and collapse four multi-line QNBS-v3 comments to the required single line. Not addressed here (documented, not fixed): the ADR-0016 wording describing Claude desktop as immediately usable is accurate once the full stacked sequence merges (Track A is PR #300, already stacked next) and will read correctly at that point without further edits; the locale-translation findings for settings.ai.grokKey reflect this repo's established i18n tiering (5 production locales get hand translations, 14 beta locales intentionally carry an EN fallback per CLAUDE.md's own i18n section), not a defect; the vi.mock hoisting concern in listenerMiddleware.test.ts is a false positive verified against the actual passing suite -- ProjectDocBinding is consumed via a dynamic await import() inside test bodies, not a static top-level import, so by the time any mock factory runs the module has already evaluated top-to-bottom once. * fix(docs): restore the '+' suffix scripts/sync-readme-metrics.mjs expects PR #301's Build, E2E Tests, and E2E Deep Coverage jobs all failed at the same root cause: pnpm run build's predev/prebuild sync step invokes sync-readme-metrics.mjs, which hard-fails via its own drift guard when a metric occurrence in README.md doesn't match the real, computed value. Four README occurrences read "6477 tests / 529 files" (no "+"), while every regex the script uses -- both its targeted replacements and the drift guard's generic check -- expects the "6477+ tests" convention the rest of the file already follows. Because the file count only changed NOW (529 -> 532, from adding claudeProxyCore.test.ts, claudeProxyEntrypoints.test.ts, and deployTarget.test.ts across Track B and the Ollama addendum), the guard fired for the first time -- the "+"- less phrasing had been silently tolerated as long as the numeric value happened to still match. Restored the "+" suffix (this repo's actual, already-established convention for a fast-changing count, not a new one) rather than adding a parallel no-"+" regex path to the script -- the script's convention was correct; my own earlier edit was the drift. Running the script now auto-updates 529 -> 532 and is idempotent on a second run. * fix(api): bound the Claude proxy rate-limiter's eviction instead of clearing it (CWE-770) A spoofed x-forwarded-for burst that pushed the tracked-client map over its 5000-entry cap triggered a wholesale rateLimitLog.clear(), resetting every real client's rate-limit window — a DoS vector against the limiter itself. Now evicts stale-window entries first, then oldest-by-insertion-order, never the current caller's own entry. Also fixes two doc-drift nitpicks CodeRabbit caught on the same PR: a stale functions/claude-proxy.ts path in two header comments (actual path is functions/api/claude-proxy.ts), and a multi-line JSDoc block in AnthropicProviderFields.tsx that should have been the repo's single-line QNBS-v3 comment format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
#266) (#302) * fix: address deferred CodeRabbit findings from PR #297 Four findings deferred from PR #297 to keep that PR's merge unblocked, addressed here first per explicit instruction before starting the Grok/Claude/Ollama plan execution: 1. GROK-PROVIDER-INTEGRATION-PLAN.md: the addendum was numbered "## 7" but its own subsections said "8.1"-"8.4", and a "§§1-7 above" self-reference included the addendum itself. Renumbered to 7.1-7.4, fixed both self-references, and fixed a second pre-existing ambiguous "§5" cross-reference (meant "item 5 within Phase 1's own list", not top-level section 5) while in the area. 2. tests/unit/listenerMiddleware.test.ts: added the required QNBS-v3 comment next to the new featureFlagsActions import. 3. Added the required QNBS-v3 comment before the new "local-first shadow sync" describe block. 4. Rewrote the local-first sync test to assert the actual selector-to-binding boundary directly instead of inferring success from an absence of logger calls in an unmocked-dependency, environment-dependent setup. Added real mocks for services/localFirst/{projectDoc,docBinding,docPersistence} and services/storage/storageEncryptionService so the test can assert the exact object selectProjectData(state) returns is what reaches ProjectDocBinding's constructor and syncFromProject/verify. Hit two real bugs while building this: (a) an arrow-function mock implementation can never be used as a constructor -- switched to a real class, which Biome's useArrowFunction rule doesn't flag either (a function-expression fix would have been immediately reverted by the same linter that was satisfied by removing the arrow function in the first place); (b) vi.fn().mockImplementation() can't type a class constructor against its generic (...args) => any signature -- mocked the class directly instead of wrapping it in vi.fn(). * docs: Phase 0 re-verification finds Claude split -- two tracks, not one Re-checked the plan's own findings against current main before starting implementation (Phase 0's explicit purpose), and found the original draft's Claude analysis was incomplete in a way that changes the whole approach: - AiProviderCard.tsx's primary provider dropdown already has an 'anthropic' entry with a dedicated warning block -- missed originally because the investigation searched for the literal string "claude", never "anthropic" (the actual identifier), when checking that file. Its existing i18n copy already half-promises desktop support ("...or use the Tauri desktop app for Anthropic calls"). - streamAnthropic() throws unconditionally on EVERY platform, including desktop -- but CORS is a browser-only restriction. It never even checks isTauriRuntime() before throwing. Tauri's native HTTP plugin (@tauri-apps/plugin-http, services/localServerHttp.ts) already solves exactly this class of problem for Ollama/LM Studio/vLLM (ADR-0012); the same escape hatch works for any HTTPS endpoint, including api.anthropic.com, since native networking isn't subject to browser CORS at all. Restructured Phase 2 into two independent tracks: Track A (desktop) is a narrow bug fix reusing ADR-0012's established pattern, no new infrastructure, ships first. Track B (web/PWA) is the actual new architecture -- the serverless proxy -- and is now correctly scoped to only the three web deploy targets (desktop no longer needs it at all). Updated the Executive Summary, both Definition of Done checklists, and all internal cross-references accordingly. * docs(adr): add ADR-0016 for native Grok provider + split Claude fix Formalizes GROK-PROVIDER-INTEGRATION-PLAN.md's Phase 0 decision record: Grok is a pure UI-wiring fix (backend already works); Claude splits into Track A (desktop, native-HTTP via the ADR-0012 pattern, ships first, no new infrastructure) and Track B (web/PWA, this app's first serverless backend dependency, ships second). Also documents why the Ollama-in-PWA follow-up (Issue #266) is a separate decision, not a Track-B variant -- a hosted proxy can't reach a user's own localhost. * feat(ai): Phase 1 -- native Grok provider wiring Grok (xAI, Cloud 4) had a real, working backend (aiProviderService.ts's streamGrok(), key storage, a live /v1/models connection test) but was never selectable as a primary provider -- reachable only as a hybrid- fallback-chain option. This closes that UI/wiring gap; no new backend work needed. - AiProviderCard.tsx: added Grok to the primary provider dropdown with its own API-key input + model selector (grok-3 / grok-3-mini), reusing the existing storageService key-storage plumbing and the already-working testAIConnection() grok case for Test Connection. Also fixed the whole provider array's i18n while touching it -- gemini/openai/ollama/anthropic labels were hardcoded literals too, not just Grok's gap. - providerFactory.ts: providerToKind() gets a distinct 'grok' kind (not folded into 'openaiCompatible') -- Grok needs a fixed baseURL (https://api.x.ai/v1) and a real stored key, unlike the Ollama default that also maps to 'openaiCompatible'; folding it in would have silently sent Grok requests through Ollama's localhost default. - worldScriptCompletionFetch.ts: resolveModelConfig() branches on the new 'grok' kind, bringing Grok into the newer Writer-streaming path (useWorldScriptAI), not just legacy thunks. - i18n: added settings.ai.grokKey + settings.ai.provider.{gemini, openai,ollama,anthropic,grok} across all 19 locales -- hand- translated for the 5 production locales (de/en/es/fr/it), EN fallback seeded for the other 14 pending the established bulk- translate workflow. Bundles rebuilt; README's key-count badge/table updated 2849 -> 2855. - Tests: providerFactory (grok maps to 'grok', not 'unsupported'), worldScriptCompletionFetch (grok success path + missing-key 401), AiProviderCard (renders key input + model selector, saves via storageService.saveApiKey('grok', ...)). No CSP change needed: https://api.x.ai is already in src-tauri/tauri.conf.json, and the web CSP's https: scheme-source (ADR-0004) already covers it. No new feature flag -- confirmed via `pnpm exec tsx scripts/audit-feature-parity.ts` (22 flags, 0 drifts). The many locales/*/{characters,common,dashboard,desktop,export, outline,worlds,writer}.json changes in this commit are incidental -- running the i18n tooling's --fix pass re-sorted keys in files it touched along the way. No content was added, removed, or changed in any of those files, only key order. * feat(ai): Phase 2 Track A -- native Claude support on desktop Claude/Anthropic's streamAnthropic() threw unconditionally on every platform, including desktop -- but CORS is a browser-only restriction. Tauri's native HTTP plugin (localServerFetch, ADR-0012) already exists in this codebase to bypass exactly this class of problem for Ollama; the same escape hatch works for any HTTPS endpoint, since native networking isn't subject to browser CORS at all. - aiProviderService.ts: streamAnthropic() branches on isTauriRuntime() before throwing -- desktop calls https://api.anthropic.com/v1/messages directly via localServerFetch (Messages API format: x-api-key + anthropic-version headers). generateTextSingleProvider()'s anthropic case now delegates to the fixed streamAnthropic instead of its own separate unconditional throw. testAIConnection()'s anthropic case gets the same branch, using a minimal (max_tokens: 1) real request as the connectivity check since Anthropic has no public /v1/models endpoint to probe. Image generation is deliberately left throwing -- Anthropic's API doesn't offer that endpoint at all, so this isn't a CORS bug to fix. - src-tauri/tauri.conf.json: added https://api.anthropic.com to the CSP connect-src (mirrors the existing Grok entry). - AiProviderCard.tsx: the existing 'anthropic' warning-only block is now desktop-conditional -- desktop renders a real API-key input + model selector (Opus/Sonnet/Haiku 4.x) exactly like every other cloud provider; web/PWA keeps the warning, with copy updated to say desktop now genuinely works rather than "might" (Track B's proxy note stays "coming soon", since that part isn't built yet). - i18n: added settings.ai.anthropicKey; revised anthropicCorsNote/ anthropicHint wording across the 5 production locales to reflect desktop actually working now; EN fallback seeded for the other 14. 2855 -> 2856 keys; README badge/table updated. - Tests: aiProviderService (testAIConnection + streamText desktop success/failure/no-key paths, web-path unchanged), AiProviderCard (desktop shows key input, web shows warning, key save wired to storageService.saveApiKey('anthropic', ...)). Track B (the web/PWA serverless proxy) is unaffected by this commit -- still not built, still the only path for browser-tab Claude. * feat(ai): Phase 2 Track B -- Claude serverless proxy for web/PWA Track A (previous commit) fixed Claude on desktop. This closes the other half: browser tabs have no native-HTTP escape hatch from CORS, so Anthropic requests from Vercel/Cloudflare Pages deployments now relay through this app's own stateless serverless proxy -- its first backend dependency ever (previously a purely static SPA + a desktop bundle with no server of its own). - api/_shared/claudeProxyCore.ts: platform-agnostic relay core (Web Request/Response only, no @vercel/node or @cloudflare/workers-types dependency). Forwards { apiKey, model, messages, maxTokens? } to Anthropic's Messages API and returns the response unmodified. Mandatory abuse controls per ADR-0016 (the endpoint is public and unauthenticated by construction -- CWE-400 surface): Zod schema validation, a 256 KiB body-size cap checked against both the Content-Length header and the actual body (defeats a spoofed header), a same-origin check (Origin must match the deployment's own host), a best-effort per-client-IP rate limit (20 req/60s, in-memory -- explicitly not distributed, see code comment), and a 20s outbound timeout to Anthropic. Never logs the key, prompt, or response on any path -- asserted directly in tests via console spies across every branch. - api/claude-proxy.ts: Vercel Edge Function entry point (thin wrapper). - functions/api/claude-proxy.ts: Cloudflare Pages Function equivalent, at the matching /api/claude-proxy route (Cloudflare has no automatic "/api" prefix the way some platforms do -- the path has to earn it). - services/deployTarget.ts: isServerlessProxyCapable() -- reuses import.meta.env.BASE_URL (already this codebase's build-time GitHub-Pages-vs-edge marker, see config/resolveViteBase.ts) to detect whether the current web build can host the proxy at all. GitHub Pages is static-only and never can. - services/aiProviderService.ts: streamAnthropic() now has three branches -- desktop (Track A, unchanged), proxy-capable web (fetches /api/claude-proxy), and GitHub Pages (throws immediately, before ever checking for a key, since no key would help). testAIConnection's anthropic case gets the same three-way split. Retired the 'backendProxyRequired' TestConnectionErrorKind (nothing can produce it anymore) in favor of 'proxyUnavailableStaticHost'. - AiProviderCard.tsx: extracted the whole anthropic UI block into components/settings/AnthropicProviderFields.tsx -- both to keep AiProviderCard under the Biome cognitive-complexity gate (52 > 50 before the split) and under CLAUDE.md's 700-line file-size target. Desktop and proxy-capable web now render the same real key + model UI every other cloud provider gets; only GitHub Pages keeps the warning block, with copy rewritten from a generic CORS note to the actual "no proxy host" reason. - i18n: settings.ai.anthropicProxyNote (new); anthropicCorsNote/ anthropicHint/corsRestriction copy rewritten (desktop and Vercel/CF no longer say "coming soon" -- they work); testError. backendProxyRequired removed, testError.proxyUnavailableStaticHost added. Hand-translated for the 5 production locales; EN fallback seeded for the other 14. 2856 -> 2857 keys; README badge/table/count updated in all 4 places. - docs/SECURITY-THREAT-MODEL.md: new "Claude serverless proxy trust-model change" section, an Information Disclosure row, a Denial of Service row (the abuse-control list), a Mitigation Mapping row, and a checklist item -- this is the first provider whose BYOK key transits infrastructure WorldScript runs, and that's stated plainly rather than folded into the general BYOK framing. - README.md: privacy bullet and the encryption section both carry the same caveat; the Cloud 3 provider-stack row lists the actual model ids (Opus 4.7/Sonnet 4.6/Haiku 4.5, not the stale "Claude 3.5 Sonnet") and where Claude does/doesn't work. - docs/adr/0016: documents why providerFactory.ts's Vercel-AI-SDK layer still returns 'unsupported' for anthropic (would need the @ai-sdk/anthropic package -- Anthropic's Messages API isn't OpenAI-Chat-Completions-shaped like Grok/Ollama are, so that's a new runtime dependency out of scope here) and why no dedicated E2E spec was added (no other cloud provider -- including Grok -- has one in this repo; the RTL/unit level is where this class of Settings-form coverage already lives). Tests: 14 new (claudeProxyCore: schema/size/origin/rate-limit/timeout/ relay-fidelity/no-logging), 3 new (Vercel + Cloudflare entry-point delegation), 3 new (deployTarget branch coverage), aiProviderService's Anthropic describe block rewritten for the three-way split, AiProviderCard's anthropic describe block rewritten with a new proxy-capable-web case. 126 tests green across the full Track B surface; lint, typecheck, i18n:check, docs:check, and parity:check (22 flags, 0 drifts -- no new flag needed) all clean. * feat(ai): Addendum -- opt-in browser-Ollama connection for the PWA (ADR-0017, Issue #266) Issue #266's own comment thread already worked out the correct answer: CORS is the server's own configuration, not an immutable browser wall. If a user starts their own Ollama server with OLLAMA_ORIGINS covering the PWA's exact origin, a direct browser fetch to localhost succeeds -- real, standards-compliant CORS, the same non-magic model NovelCrafter's own browser-Ollama support uses. This wires that up as an explicit, default-off opt-in, without touching the "PWA stays desktop-only by default" guarantee ADR-0012 established. - features/featureFlags/featureFlagsSlice.ts: new enableBrowserOllama flag, default off (23rd flag, 7th user opt-in). - features/featureCatalog.ts: catalog entry (tier: ai, risk: medium, matches enableVoiceSupport's classification for a similarly-scoped opt-in) with real gateLocations. - hooks/useSettingsView.ts: dispatch case for the new flag (caught by scripts/audit-feature-parity.ts's "toggle fires, Redux doesn't update" check -- exactly the class of drift that gate exists for). - services/aiProviderService.ts: testAIConnection's ollama case now accepts opts.browserOllamaEnabled instead of hard-requiring isTauriRuntime(); a generic 'unreachable' result remaps to a new 'corsSuspected' kind when running the opt-in browser path -- CORS rejection and "server genuinely down" are identical TypeErrors at the JS level, so this is an honestly-hedged heuristic, not a diagnosis. - services/localServerHttp.ts: NO functional change -- verified during implementation that resolveFetch() already returns globalThis.fetch unconditionally on the web; the "PWA never probes localhost" policy lived entirely in the UI/testAIConnection call sites, not the transport layer. Added a comment explaining why, so a future reader doesn't go looking for a change that was never needed here. - components/settings/AiProviderCard.tsx (+ AiSections.tsx passing the flag down): canAttemptOllama = isDesktop || browserOllamaEnabled replaces every bare isDesktop check gating the ollama auto-probe effect, Load Models, and Test Connection. When on, an info block renders the exact `OLLAMA_ORIGINS=<origin> ollama serve` command for window.location.origin -- always correct for the current deployment, never a guessed/generic value, since WorldScript ships from multiple possible origins (unlike NovelCrafter's single fixed SaaS URL). - docs/adr/0017-pwa-browser-ollama-opt-in.md: new ADR: why this differs from the Claude proxy pattern (a hosted function can reach the public internet, never a user's own localhost -- hard networking fact, not policy), why WorldScript never defaulted this on unlike NovelCrafter, explicit non-goals (no LAN-IP, no PNA, not the default). ADR-0012 cross-referenced both directions. - docs/FEATURE-PARITY.md, CLAUDE.md (x2), tests/CLAUDE.md, AGENTS.md (x2), .github/copilot-instructions.md: flag-count references updated 22->23 total / 6->7 opt-in-off across every doc that enumerates them. - i18n: settings.featureFlags.enableBrowserOllama, ai.ollamaBrowserOptInTitle/ Body, ai.testError.corsSuspected -- hand-translated for the 5 production locales, EN fallback seeded for the other 14. 2857 -> 2861 keys; README updated in all 4 places. - Help articles (help.aiStudio.providers.content, help.faq.api.content, 5 production locales): fixed pre-existing stale content discovered while updating for this work -- wrong model names (Claude 3.5 Sonnet/Grok-2, both outdated), a genuine CSP-vs-CORS factual error in the Ollama description (CORS blocks localhost, not CSP -- the exact misconception ADR-0012 already corrected elsewhere), and a missing OpenRouter entry (shipped as Cloud 5, never documented in either article). Not scope creep: same articles, same edit, left broken would misinform users reading about the very feature this PR ships. - README.md: Ollama provider-stack row now documents both the desktop native path and the new opt-in browser path with the ADR-0017 link. Tests: 5 new (testAIConnection's browserOllamaEnabled on/off, corsSuspected remapping, non-remap of timeout/success), 5 new (AiProviderCard's flag-off/on UI states, auto-probe, button enablement, desktop-unaffected), 1 new (useSettingsView's dispatch case), plus the parametrized featureFlagsSlice case and updated 21->22 toggle count in FeatureFlagsSection. 336 tests green across the full affected surface; lint, typecheck, i18n:check, docs:check, and parity:check (23 flags, 0 drifts) all clean. * fix(tokens): avoid token-audit false-positive from #266 issue references audit-tokens.mjs's raw-hex rule (/#[0-9a-fA-F]{3,8}\b/) can't distinguish a CSS color literal from a GitHub issue reference -- "#266" matches just as well as "#f0f". The previous commit's two brand-new comments referencing Issue #266 (an info-block comment in AiProviderCard.tsx, a roadmapTarget string in featureCatalog.ts) pushed the count to 161 against a baseline of 160. Reworded both to drop the "#" immediately before the digits rather than raising the baseline -- same rule this repo already applies to biome-ignore suppressions. * docs: address CodeRabbit quick-win findings on PR #299 Add missing single-line QNBS-v3 rationale comments (Grok key state, key-persistence handler, the openaiCompatible-reuse decision in worldScriptCompletionFetch.ts, two test fixtures/blocks) and collapse four multi-line QNBS-v3 comments to the required single line. Not addressed here (documented, not fixed): the ADR-0016 wording describing Claude desktop as immediately usable is accurate once the full stacked sequence merges (Track A is PR #300, already stacked next) and will read correctly at that point without further edits; the locale-translation findings for settings.ai.grokKey reflect this repo's established i18n tiering (5 production locales get hand translations, 14 beta locales intentionally carry an EN fallback per CLAUDE.md's own i18n section), not a defect; the vi.mock hoisting concern in listenerMiddleware.test.ts is a false positive verified against the actual passing suite -- ProjectDocBinding is consumed via a dynamic await import() inside test bodies, not a static top-level import, so by the time any mock factory runs the module has already evaluated top-to-bottom once. * fix(docs): restore the '+' suffix scripts/sync-readme-metrics.mjs expects PR #301's Build, E2E Tests, and E2E Deep Coverage jobs all failed at the same root cause: pnpm run build's predev/prebuild sync step invokes sync-readme-metrics.mjs, which hard-fails via its own drift guard when a metric occurrence in README.md doesn't match the real, computed value. Four README occurrences read "6477 tests / 529 files" (no "+"), while every regex the script uses -- both its targeted replacements and the drift guard's generic check -- expects the "6477+ tests" convention the rest of the file already follows. Because the file count only changed NOW (529 -> 532, from adding claudeProxyCore.test.ts, claudeProxyEntrypoints.test.ts, and deployTarget.test.ts across Track B and the Ollama addendum), the guard fired for the first time -- the "+"- less phrasing had been silently tolerated as long as the numeric value happened to still match. Restored the "+" suffix (this repo's actual, already-established convention for a fast-changing count, not a new one) rather than adding a parallel no-"+" regex path to the script -- the script's convention was correct; my own earlier edit was the drift. Running the script now auto-updates 529 -> 532 and is idempotent on a second run. * fix(api): bound the Claude proxy rate-limiter's eviction instead of clearing it (CWE-770) A spoofed x-forwarded-for burst that pushed the tracked-client map over its 5000-entry cap triggered a wholesale rateLimitLog.clear(), resetting every real client's rate-limit window — a DoS vector against the limiter itself. Now evicts stale-window entries first, then oldest-by-insertion-order, never the current caller's own entry. Also fixes two doc-drift nitpicks CodeRabbit caught on the same PR: a stale functions/claude-proxy.ts path in two header comments (actual path is functions/api/claude-proxy.ts), and a multi-line JSDoc block in AnthropicProviderFields.tsx that should have been the repo's single-line QNBS-v3 comment format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
PR 2 of 4 in the stacked sequence (stacked on #299 — Grok). Auto-retargets to
mainonce #299 merges.CORS is a browser restriction — Tauri's native HTTP plugin (
localServerFetch, ADR-0012) isn't subject to it, so desktop can call Anthropic directly instead of throwing unconditionally likestreamAnthropic()previously did on every platform.streamAnthropic()branches onisTauriRuntime()— desktop callshttps://api.anthropic.com/v1/messagesdirectly vialocalServerFetch(Messages API:x-api-key+anthropic-versionheaders).testAIConnection's anthropic case gets the same branch, using a minimal (max_tokens: 1) real request as the connectivity check (Anthropic has no public/v1/modelsendpoint).src-tauri/tauri.conf.jsonCSP: addedhttps://api.anthropic.comtoconnect-src(mirrors the existing Grok entry).AiProviderCard.tsx: the existing anthropic warning-only block is now desktop-conditional — desktop renders a real key input + model selector (Opus 4.7/Sonnet 4.6/Haiku 4.5), matching every other cloud provider.Web/PWA is unaffected by this PR — still shows the warning, pointing at Track B (next PR in the stack).
Test plan
pnpm run lint/pnpm run typecheck— cleanpnpm run i18n:check— 2856 keyspnpm run docs:check— cleannode scripts/audit-tokens.mjs— 160 ≤ baselineaiProviderService.test.ts+AiProviderCard.test.tsx— 78 tests green🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation