{"slug": "ai-as-an-optional-capability-not-an-application-dependency", "title": "AI as an Optional Capability, Not an Application Dependency", "summary": "WorldScript Studio, an open-source writing studio, is architected so its AI features are optional rather than load-bearing: all AI capabilities route through a single provider seam (Gemini, OpenAI, OpenRouter, Anthropic, and OpenAI-compatible local servers), with policy gates enforced as throwing code in aiPolicy.ts and a fallback layer permitted to report that no fallback exists. The project's author reports that cloud providers are blocked in local and eco modes, and that LoRA fine-tuning is restricted by allowlist to local providers so manuscript data never leaves the device. The code references come from repository commit 2d9157c0, release v1.28.8.", "body_md": "Try a small experiment with your favorite AI-powered app: revoke the API key, switch off the network, and open it again. A surprising number of products fail this test — not the AI features, the *product*. Spinners that never resolve. A settings page that errors out. A startup sequence blocked on a model ping. Somewhere between the demo and the release, the AI integration stopped being a feature and became load-bearing infrastructure.\n\nWorldScript Studio is an open-source writing studio where AI assists with things like outlines, character work, and prose feedback. It is also, by design, fully usable with no key, no model, and no network. This article is about the mechanisms that keep it that way — a provider seam, policy gates in code, honest failure semantics, and a fallback layer that is allowed to say \"no fallback exists.\" Code references are from the repository at commit `2d9157c0` (2026-09-28), release v1.28.8; simplified excerpts are labeled.\n\nPlenty of apps have an \"AI: off\" switch. Fewer have an architecture where off is a real, tested state. The difference shows up the first time a provider has an outage and your error handling turns out to be a toast notification saying \"something went wrong\" above a dead feature.\n\nI ended up with a rule: **the AI layer may fail in every way it wants, as long as the failure is typed, explained, and contained.** Four mechanisms enforce it.\n\nEvery AI capability in the app — text generation, structured JSON, streaming, image generation — goes through one unified provider service. Providers sit behind it as adapters: Gemini, OpenAI, OpenRouter, Anthropic, and any OpenAI-compatible local server (Ollama, LM Studio) selected by base URL. A small factory normalizes them into a single `LanguageModel` type from the Vercel AI SDK:\n\n```\n// services/ai/providerFactory.ts (excerpt, comments trimmed)\nexport type WorldScriptLanguageModelConfig =\n  | { provider: 'gemini'; modelId: string; apiKey: string }\n  | { provider: 'openai'; modelId: string; apiKey: string;\n      headers?: Record<string, string> }\n  | { provider: 'openaiCompatible'; baseURL: string; apiKey: string;\n      modelId: string; headers?: Record<string, string> };\n\nexport function createLanguageModelForWorldScript(\n  config: WorldScriptLanguageModelConfig,\n): LanguageModel { /* … */ }\n```\n\nThe point of the seam is not abstraction for its own sake. It is that \"the AI is down\" has exactly one place to happen. Features never talk to a provider directly, so no feature can accidentally grow its own retry logic, its own key handling, or its own definition of what \"offline\" means.\n\nBring-your-own-key lives behind the same seam: keys are stored encrypted (in the browser build, under a random non-extractable AES-256-GCM key in IndexedDB), and no provider SDK ever sees storage.\n\nRouting is user-visible: four modes — `hybrid`, `cloud`, `local`, `eco` — with hybrid as the default. The part that matters architecturally is that the rules are enforced as code at the seam, not as conventions spread across components:\n\n```\n// services/ai/aiPolicy.ts (excerpt)\nexport function assertCloudAiAllowedSync(\n  provider: AIProvider,\n  privacy: PrivacySettings | undefined,\n): void {\n  if (LOCAL_INFERENCE_PROVIDERS.has(provider)) return;\n  const mode = getActiveAiMode();\n  if (mode === 'local' || mode === 'eco') {\n    throw new Error(`Cloud provider blocked: AI mode is \"${mode}\" (local-only).`);\n  }\n  if (!privacy) return;\n  if (privacy.localStorageOnly) {\n    throw new Error('Cloud provider blocked: local-only mode is active.');\n  }\n  // …\n}\n```\n\nA gate that throws is testable in a way a gate that \"should be checked\" is not — the policy test suite exercises the mode matrix directly. The same file carries a second hard gate: model training (LoRA fine-tuning) is restricted to local providers by allowlist, because training data is manuscript data, and manuscript data does not leave the device for that path. Note the scope of that sentence: it describes the training path, not a blanket privacy guarantee for every AI feature — a cloud provider you explicitly call necessarily receives the context you send it.\n\nProvider errors are not one thing. A rate limit is not a wrong API key, and neither resembles \"the laptop is offline.\" The seam classifies every failure into a small taxonomy:\n\n| Category | Retryable? | Why | \n|---|---|---|\n| transient, rate limit, network | yes | connection-class; a later attempt can succeed | \n| auth, policy, invalid request | no | deterministic — retrying repeats the failure | \n| offline | no | doomed until connectivity returns | \n| canceled, permanent | no | user intent / unrecoverable | \n\nEach class carries a stable message key, so the UI can say \"check your key\" or \"you are offline\" instead of showing a generic error — and so the retry layer fails fast on doomed calls instead of backing off politely on a request that will never succeed. This is the difference between a degraded app and a lying app.\n\nWhen an AI call is terminally unavailable, some features can fall back to local heuristic generators — registered per task (an outline generator, a character-profile generator). The design decision I care about most is what the registry does when nothing is registered:\n\n```\nrunHeuristicFallback(task, ctx)\n  → generator registered?  run it, return its result\n  → nothing registered?    return null\n                           caller keeps its existing behavior\n```\n\nThe fallback layer is \"always safe to ship empty,\" as the code comment puts it. `null` means: no fallback exists, tell the user the feature needs a configured provider. What the registry never does is invent a plausible-looking answer to cover for a missing model. A fallback that quietly fabricates quality is worse than an honest refusal, because users cannot calibrate trust against output they cannot distinguish from the real thing.\n\n```\n feature call\n      │\n      ▼\n unified provider seam ──► policy gate (mode, privacy) ──throws──► typed error\n      │                                                            │\n      ▼                                                            ▼\n provider adapter (cloud/local)                          UI hint via message key\n      │                                                            │\n      ├─ terminal failure ──► heuristic fallback ──null──► honest refusal\n      ▼\n streaming response\n```\n\n*Simplified: the real chain includes cancellation, request deduplication, and a provider fallback chain for transient cloud failures.*\n\nPrecision matters more than marketing, so: with no network, the manuscript editor, planning tools, and storage work fully — they never touch the seam. Browser-local and Ollama-served models keep working if they were set up beforehand; the first model download obviously needs a connection, and local inference needs hardware that can carry it. Cloud providers are unreachable, and the offline error class says so. Hybrid mode is deliberately cloud-first; `local` mode is the setting that guarantees the device never emits a request.\n\nAI being optional also means the app's identity does not collapse without it. There is no onboarding step that demands a key, no feature gate on the editor, no telemetry about your text leaving for a server you did not choose.\n\nThen run the unplug test in CI or by hand: no key, no network, cold start. Whatever still works is your product. Whatever breaks that should not have is your real dependency graph.\n\n*Source note: WorldScript Studio is open source ([github.com/qnbs/WorldScript-Studio](https://github.com/qnbs/WorldScript-Studio)). Code references correspond to `main` at `2d9157c0` (2026-09-28); release anchor v1.28.8. Key files: `services/aiProviderService.ts`, `services/ai/providerFactory.ts`, `services/ai/aiPolicy.ts`, `services/ai/aiErrorTaxonomy.ts`, `services/ai/heuristicFallback/registry.ts`. Part of the \"Engineering WorldScript Studio\" series.*\n\n*AI disclosure: AI tools helped with repository research, structure, and editing of this article. I reviewed the technical claims against the referenced source files before publication.*", "url": "https://wpnews.pro/news/ai-as-an-optional-capability-not-an-application-dependency", "canonical_source": "https://dev.to/qnbs/ai-as-an-optional-capability-not-an-application-dependency-20jf", "published_at": "2026-09-27 22:49:19+00:00", "updated_at": "2026-09-27 23:00:57.152226+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools", "ai-safety", "large-language-models"], "entities": ["WorldScript Studio", "Gemini", "OpenAI", "OpenRouter", "Anthropic", "Ollama", "LM Studio", "Vercel AI SDK"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/ai-as-an-optional-capability-not-an-application-dependency", "markdown": "https://wpnews.pro/news/ai-as-an-optional-capability-not-an-application-dependency.md", "text": "https://wpnews.pro/news/ai-as-an-optional-capability-not-an-application-dependency.txt", "jsonld": "https://wpnews.pro/news/ai-as-an-optional-capability-not-an-application-dependency.jsonld"}}