@mars-stack/cli 0.2.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,617 @@
1
+ # Skill: Configure AI Integration
2
+
3
+ Set up an AI provider (OpenAI or Anthropic) in a MARS application with streaming, structured output, and cost management.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add AI, LLM, chatbot, content generation, embeddings, RAG, or integrate OpenAI / Anthropic / GPT / Claude.
8
+
9
+ ## Prerequisites
10
+
11
+ - `appConfig.features.ai` set to `true` (add it if missing)
12
+ - `appConfig.services.ai.provider` set to `'openai'` or `'anthropic'`
13
+ - An API key from the chosen provider
14
+
15
+ ## Step 1: Install the SDK
16
+
17
+ **OpenAI:**
18
+
19
+ ```bash
20
+ yarn add openai
21
+ ```
22
+
23
+ **Anthropic:**
24
+
25
+ ```bash
26
+ yarn add @anthropic-ai/sdk
27
+ ```
28
+
29
+ ## Step 2: Environment Variables
30
+
31
+ **OpenAI:**
32
+
33
+ ```bash
34
+ OPENAI_API_KEY="sk-..."
35
+ OPENAI_MODEL="gpt-4o"
36
+ ```
37
+
38
+ **Anthropic:**
39
+
40
+ ```bash
41
+ ANTHROPIC_API_KEY="sk-ant-..."
42
+ ANTHROPIC_MODEL="claude-sonnet-4-20250514"
43
+ ```
44
+
45
+ Add the variables to `src/core/env/` via `buildEnvSchema()` with lazy validation so the app doesn't crash at import time when the key is absent.
46
+
47
+ ## Step 3: Define the Provider Interface
48
+
49
+ ```typescript
50
+ // src/features/ai/types.ts
51
+
52
+ export interface ChatMessage {
53
+ role: 'system' | 'user' | 'assistant';
54
+ content: string;
55
+ }
56
+
57
+ export interface ChatParams {
58
+ messages: ChatMessage[];
59
+ model?: string;
60
+ temperature?: number;
61
+ maxTokens?: number;
62
+ jsonMode?: boolean;
63
+ tools?: ToolDefinition[];
64
+ }
65
+
66
+ export interface ChatResponse {
67
+ content: string;
68
+ usage: { promptTokens: number; completionTokens: number; totalTokens: number };
69
+ finishReason: string;
70
+ }
71
+
72
+ export interface ToolDefinition {
73
+ name: string;
74
+ description: string;
75
+ parameters: Record<string, unknown>;
76
+ }
77
+
78
+ export interface AIProvider {
79
+ chat(params: ChatParams): Promise<ChatResponse>;
80
+ stream(params: ChatParams): ReadableStream;
81
+ }
82
+ ```
83
+
84
+ ## Step 4: Create the AI Client
85
+
86
+ ### OpenAI Provider
87
+
88
+ ```typescript
89
+ // src/lib/core/ai/client.ts
90
+ import 'server-only';
91
+
92
+ import OpenAI from 'openai';
93
+
94
+ let _client: OpenAI | null = null;
95
+
96
+ export function createOpenAIClient(): OpenAI {
97
+ if (_client) return _client;
98
+
99
+ const apiKey = process.env.OPENAI_API_KEY;
100
+ if (!apiKey) {
101
+ throw new Error(
102
+ 'OPENAI_API_KEY is not set.\n'
103
+ + ' → Get your key from https://platform.openai.com/api-keys\n'
104
+ + ' → Add it to your .env file',
105
+ );
106
+ }
107
+
108
+ _client = new OpenAI({ apiKey });
109
+ return _client;
110
+ }
111
+ ```
112
+
113
+ ### OpenAI AIProvider Implementation
114
+
115
+ ```typescript
116
+ // src/features/ai/server/openai-provider.ts
117
+ import 'server-only';
118
+
119
+ import { createOpenAIClient } from '@/lib/core/ai/client';
120
+ import type { AIProvider, ChatParams, ChatResponse } from '../types';
121
+
122
+ const DEFAULT_MODEL = process.env.OPENAI_MODEL || 'gpt-4o';
123
+
124
+ export const openaiProvider: AIProvider = {
125
+ async chat(params: ChatParams): Promise<ChatResponse> {
126
+ const client = createOpenAIClient();
127
+
128
+ const response = await client.chat.completions.create({
129
+ model: params.model || DEFAULT_MODEL,
130
+ messages: params.messages,
131
+ temperature: params.temperature ?? 0.7,
132
+ max_tokens: params.maxTokens,
133
+ ...(params.jsonMode && { response_format: { type: 'json_object' } }),
134
+ ...(params.tools?.length && {
135
+ tools: params.tools.map((t) => ({
136
+ type: 'function' as const,
137
+ function: {
138
+ name: t.name,
139
+ description: t.description,
140
+ parameters: t.parameters,
141
+ },
142
+ })),
143
+ }),
144
+ });
145
+
146
+ const choice = response.choices[0];
147
+
148
+ return {
149
+ content: choice.message.content ?? '',
150
+ usage: {
151
+ promptTokens: response.usage?.prompt_tokens ?? 0,
152
+ completionTokens: response.usage?.completion_tokens ?? 0,
153
+ totalTokens: response.usage?.total_tokens ?? 0,
154
+ },
155
+ finishReason: choice.finish_reason,
156
+ };
157
+ },
158
+
159
+ stream(params: ChatParams): ReadableStream {
160
+ const client = createOpenAIClient();
161
+
162
+ return new ReadableStream({
163
+ async start(controller) {
164
+ const stream = await client.chat.completions.create({
165
+ model: params.model || DEFAULT_MODEL,
166
+ messages: params.messages,
167
+ temperature: params.temperature ?? 0.7,
168
+ max_tokens: params.maxTokens,
169
+ stream: true,
170
+ });
171
+
172
+ for await (const chunk of stream) {
173
+ const content = chunk.choices[0]?.delta?.content;
174
+ if (content) {
175
+ controller.enqueue(new TextEncoder().encode(content));
176
+ }
177
+ }
178
+
179
+ controller.close();
180
+ },
181
+ });
182
+ },
183
+ };
184
+ ```
185
+
186
+ ### Anthropic Provider
187
+
188
+ ```typescript
189
+ // src/lib/core/ai/anthropic-client.ts
190
+ import 'server-only';
191
+
192
+ import Anthropic from '@anthropic-ai/sdk';
193
+
194
+ let _client: Anthropic | null = null;
195
+
196
+ export function createAnthropicClient(): Anthropic {
197
+ if (_client) return _client;
198
+
199
+ const apiKey = process.env.ANTHROPIC_API_KEY;
200
+ if (!apiKey) {
201
+ throw new Error(
202
+ 'ANTHROPIC_API_KEY is not set.\n'
203
+ + ' → Get your key from https://console.anthropic.com/settings/keys\n'
204
+ + ' → Add it to your .env file',
205
+ );
206
+ }
207
+
208
+ _client = new Anthropic({ apiKey });
209
+ return _client;
210
+ }
211
+ ```
212
+
213
+ ### Anthropic AIProvider Implementation
214
+
215
+ ```typescript
216
+ // src/features/ai/server/anthropic-provider.ts
217
+ import 'server-only';
218
+
219
+ import { createAnthropicClient } from '@/lib/core/ai/anthropic-client';
220
+ import type { AIProvider, ChatParams, ChatResponse } from '../types';
221
+
222
+ const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514';
223
+
224
+ export const anthropicProvider: AIProvider = {
225
+ async chat(params: ChatParams): Promise<ChatResponse> {
226
+ const client = createAnthropicClient();
227
+
228
+ const systemMessage = params.messages.find((m) => m.role === 'system');
229
+ const nonSystemMessages = params.messages.filter((m) => m.role !== 'system');
230
+
231
+ const response = await client.messages.create({
232
+ model: params.model || DEFAULT_MODEL,
233
+ max_tokens: params.maxTokens ?? 4096,
234
+ ...(systemMessage && { system: systemMessage.content }),
235
+ messages: nonSystemMessages.map((m) => ({
236
+ role: m.role as 'user' | 'assistant',
237
+ content: m.content,
238
+ })),
239
+ ...(params.tools?.length && {
240
+ tools: params.tools.map((t) => ({
241
+ name: t.name,
242
+ description: t.description,
243
+ input_schema: t.parameters,
244
+ })),
245
+ }),
246
+ });
247
+
248
+ const textBlock = response.content.find((b) => b.type === 'text');
249
+
250
+ return {
251
+ content: textBlock?.text ?? '',
252
+ usage: {
253
+ promptTokens: response.usage.input_tokens,
254
+ completionTokens: response.usage.output_tokens,
255
+ totalTokens: response.usage.input_tokens + response.usage.output_tokens,
256
+ },
257
+ finishReason: response.stop_reason ?? 'end_turn',
258
+ };
259
+ },
260
+
261
+ stream(params: ChatParams): ReadableStream {
262
+ const client = createAnthropicClient();
263
+
264
+ const systemMessage = params.messages.find((m) => m.role === 'system');
265
+ const nonSystemMessages = params.messages.filter((m) => m.role !== 'system');
266
+
267
+ return new ReadableStream({
268
+ async start(controller) {
269
+ const stream = client.messages.stream({
270
+ model: params.model || DEFAULT_MODEL,
271
+ max_tokens: params.maxTokens ?? 4096,
272
+ ...(systemMessage && { system: systemMessage.content }),
273
+ messages: nonSystemMessages.map((m) => ({
274
+ role: m.role as 'user' | 'assistant',
275
+ content: m.content,
276
+ })),
277
+ });
278
+
279
+ for await (const event of stream) {
280
+ if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
281
+ controller.enqueue(new TextEncoder().encode(event.delta.text));
282
+ }
283
+ }
284
+
285
+ controller.close();
286
+ },
287
+ });
288
+ },
289
+ };
290
+ ```
291
+
292
+ ## Step 5: Provider Factory
293
+
294
+ ```typescript
295
+ // src/features/ai/server/index.ts
296
+ import 'server-only';
297
+
298
+ import { appConfig } from '@/config/app.config';
299
+ import type { AIProvider } from '../types';
300
+
301
+ export async function getAIProvider(): Promise<AIProvider> {
302
+ const provider = appConfig.services?.ai?.provider ?? 'openai';
303
+
304
+ switch (provider) {
305
+ case 'anthropic': {
306
+ const { anthropicProvider } = await import('./anthropic-provider');
307
+ return anthropicProvider;
308
+ }
309
+ case 'openai':
310
+ default: {
311
+ const { openaiProvider } = await import('./openai-provider');
312
+ return openaiProvider;
313
+ }
314
+ }
315
+ }
316
+ ```
317
+
318
+ ## Step 6: OpenAI Embeddings (for RAG)
319
+
320
+ ```typescript
321
+ // src/features/ai/server/embeddings.ts
322
+ import 'server-only';
323
+
324
+ import { createOpenAIClient } from '@/lib/core/ai/client';
325
+
326
+ export async function createEmbedding(text: string): Promise<number[]> {
327
+ const client = createOpenAIClient();
328
+ const response = await client.embeddings.create({
329
+ model: 'text-embedding-3-small',
330
+ input: text,
331
+ });
332
+ return response.data[0].embedding;
333
+ }
334
+
335
+ export async function createEmbeddings(texts: string[]): Promise<number[][]> {
336
+ const client = createOpenAIClient();
337
+ const response = await client.embeddings.create({
338
+ model: 'text-embedding-3-small',
339
+ input: texts,
340
+ });
341
+ return response.data.map((d) => d.embedding);
342
+ }
343
+ ```
344
+
345
+ ## Step 7: Streaming API Route
346
+
347
+ ```typescript
348
+ // src/app/api/protected/ai/chat/route.ts
349
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
350
+ import { getAIProvider } from '@/features/ai/server';
351
+ import { checkRateLimit, RATE_LIMITS } from '@mars-stack/core/rate-limit';
352
+ import { z } from 'zod';
353
+
354
+ const chatSchema = z.object({
355
+ messages: z.array(z.object({
356
+ role: z.enum(['system', 'user', 'assistant']),
357
+ content: z.string().min(1).max(32_000),
358
+ })).min(1).max(100),
359
+ stream: z.boolean().default(true),
360
+ });
361
+
362
+ const AI_RATE_LIMIT = { maxRequests: 30, windowMs: 60_000 };
363
+
364
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
365
+ try {
366
+ await checkRateLimit(request.session.userId, AI_RATE_LIMIT);
367
+ const { messages, stream } = chatSchema.parse(await request.json());
368
+ const provider = await getAIProvider();
369
+
370
+ if (stream) {
371
+ const readableStream = provider.stream({ messages });
372
+ return new Response(readableStream, {
373
+ headers: {
374
+ 'Content-Type': 'text/plain; charset=utf-8',
375
+ 'Transfer-Encoding': 'chunked',
376
+ 'Cache-Control': 'no-cache',
377
+ },
378
+ });
379
+ }
380
+
381
+ const response = await provider.chat({ messages });
382
+ return Response.json(response);
383
+ } catch (error) {
384
+ return handleApiError(error, { endpoint: '/api/protected/ai/chat' });
385
+ }
386
+ });
387
+ ```
388
+
389
+ ## Step 8: Usage Logging and Cost Management
390
+
391
+ ```typescript
392
+ // src/features/ai/server/usage.ts
393
+ import 'server-only';
394
+
395
+ import { prisma } from '@/lib/prisma';
396
+ import { logger } from '@/lib/mars';
397
+
398
+ interface UsageRecord {
399
+ userId: string;
400
+ model: string;
401
+ promptTokens: number;
402
+ completionTokens: number;
403
+ endpoint: string;
404
+ }
405
+
406
+ export async function logUsage(record: UsageRecord): Promise<void> {
407
+ logger.info('ai.usage', {
408
+ userId: record.userId,
409
+ model: record.model,
410
+ promptTokens: record.promptTokens,
411
+ completionTokens: record.completionTokens,
412
+ totalTokens: record.promptTokens + record.completionTokens,
413
+ endpoint: record.endpoint,
414
+ });
415
+ }
416
+
417
+ export async function getUserTokenUsage(
418
+ userId: string,
419
+ since: Date,
420
+ ): Promise<number> {
421
+ // Query your usage tracking table or aggregated logs
422
+ // This is a placeholder — implement based on your storage strategy
423
+ return 0;
424
+ }
425
+ ```
426
+
427
+ ## Step 9: Feature Directory Structure
428
+
429
+ ```
430
+ src/
431
+ ├── lib/core/ai/
432
+ │ ├── client.ts # OpenAI client (lazy singleton)
433
+ │ └── anthropic-client.ts # Anthropic client (lazy singleton)
434
+ ├── features/ai/
435
+ │ ├── types.ts # AIProvider interface, ChatParams, ChatResponse
436
+ │ ├── server/
437
+ │ │ ├── index.ts # Provider factory (getAIProvider)
438
+ │ │ ├── openai-provider.ts # OpenAI AIProvider implementation
439
+ │ │ ├── anthropic-provider.ts # Anthropic AIProvider implementation
440
+ │ │ ├── embeddings.ts # Embedding generation for RAG
441
+ │ │ └── usage.ts # Token usage logging and cost tracking
442
+ │ ├── hooks/
443
+ │ │ └── use-chat.ts # Client-side chat hook (optional)
444
+ │ └── validation/
445
+ │ └── schemas.ts # Zod schemas for AI API inputs
446
+ └── app/api/protected/ai/
447
+ └── chat/
448
+ └── route.ts # Streaming chat endpoint
449
+ ```
450
+
451
+ ## Step 10: Client-Side Chat Hook (Optional)
452
+
453
+ ```typescript
454
+ // src/features/ai/hooks/use-chat.ts
455
+ 'use client';
456
+
457
+ import { useState, useCallback } from 'react';
458
+ import type { ChatMessage } from '../types';
459
+
460
+ interface UseChatOptions {
461
+ onError?: (error: Error) => void;
462
+ }
463
+
464
+ export function useChat(options: UseChatOptions = {}) {
465
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
466
+ const [isLoading, setIsLoading] = useState(false);
467
+
468
+ const send = useCallback(async (content: string) => {
469
+ const userMessage: ChatMessage = { role: 'user', content };
470
+ const updatedMessages = [...messages, userMessage];
471
+ setMessages(updatedMessages);
472
+ setIsLoading(true);
473
+
474
+ try {
475
+ const response = await fetch('/api/protected/ai/chat', {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/json' },
478
+ body: JSON.stringify({ messages: updatedMessages, stream: true }),
479
+ });
480
+
481
+ if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
482
+
483
+ const reader = response.body?.getReader();
484
+ if (!reader) throw new Error('No response stream');
485
+
486
+ const decoder = new TextDecoder();
487
+ let assistantContent = '';
488
+
489
+ setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
490
+
491
+ while (true) {
492
+ const { done, value } = await reader.read();
493
+ if (done) break;
494
+
495
+ assistantContent += decoder.decode(value, { stream: true });
496
+ setMessages((prev) => {
497
+ const next = [...prev];
498
+ next[next.length - 1] = { role: 'assistant', content: assistantContent };
499
+ return next;
500
+ });
501
+ }
502
+ } catch (error) {
503
+ options.onError?.(error instanceof Error ? error : new Error(String(error)));
504
+ } finally {
505
+ setIsLoading(false);
506
+ }
507
+ }, [messages, options]);
508
+
509
+ const reset = useCallback(() => setMessages([]), []);
510
+
511
+ return { messages, isLoading, send, reset };
512
+ }
513
+ ```
514
+
515
+ ## Common Patterns
516
+
517
+ ### Content Generation
518
+
519
+ ```typescript
520
+ const provider = await getAIProvider();
521
+ const result = await provider.chat({
522
+ messages: [
523
+ { role: 'system', content: 'You are a professional copywriter.' },
524
+ { role: 'user', content: `Write a product description for: ${product.name}` },
525
+ ],
526
+ temperature: 0.8,
527
+ });
528
+ ```
529
+
530
+ ### Structured Output (JSON Mode — OpenAI)
531
+
532
+ ```typescript
533
+ const result = await provider.chat({
534
+ messages: [
535
+ { role: 'system', content: 'Extract entities from the text. Respond with JSON: { "entities": [{ "name": string, "type": string }] }' },
536
+ { role: 'user', content: userText },
537
+ ],
538
+ jsonMode: true,
539
+ });
540
+ const parsed = JSON.parse(result.content);
541
+ ```
542
+
543
+ ### Classification
544
+
545
+ ```typescript
546
+ const result = await provider.chat({
547
+ messages: [
548
+ { role: 'system', content: 'Classify the support ticket. Respond with one of: billing, technical, account, general.' },
549
+ { role: 'user', content: ticket.body },
550
+ ],
551
+ temperature: 0,
552
+ maxTokens: 10,
553
+ });
554
+ ```
555
+
556
+ ### Summarization
557
+
558
+ ```typescript
559
+ const result = await provider.chat({
560
+ messages: [
561
+ { role: 'system', content: 'Summarize the following in 2-3 sentences.' },
562
+ { role: 'user', content: longDocument },
563
+ ],
564
+ temperature: 0.3,
565
+ });
566
+ ```
567
+
568
+ ## Config Integration
569
+
570
+ Add to `app.config.ts`:
571
+
572
+ ```typescript
573
+ services: {
574
+ ai: {
575
+ provider: 'openai', // or 'anthropic'
576
+ },
577
+ },
578
+ features: {
579
+ ai: true,
580
+ },
581
+ ```
582
+
583
+ ## Tests
584
+
585
+ ```typescript
586
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
587
+
588
+ vi.mock('@/lib/core/ai/client', () => ({
589
+ createOpenAIClient: vi.fn(() => ({
590
+ chat: {
591
+ completions: {
592
+ create: vi.fn().mockResolvedValue({
593
+ choices: [{ message: { content: 'Hello!' }, finish_reason: 'stop' }],
594
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
595
+ }),
596
+ },
597
+ },
598
+ })),
599
+ }));
600
+ ```
601
+
602
+ ## Checklist
603
+
604
+ - [ ] AI SDK installed (`openai` or `@anthropic-ai/sdk`)
605
+ - [ ] Environment variables set (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`)
606
+ - [ ] Lazy client created in `src/lib/core/ai/`
607
+ - [ ] `AIProvider` interface defined in `src/features/ai/types.ts`
608
+ - [ ] Provider implementation(s) in `src/features/ai/server/`
609
+ - [ ] Provider factory in `src/features/ai/server/index.ts`
610
+ - [ ] All server modules import `'server-only'`
611
+ - [ ] Streaming chat API route with per-user rate limiting
612
+ - [ ] Zod validation on all API inputs
613
+ - [ ] Auth wrapper (`withAuthNoParams`) on protected route
614
+ - [ ] Usage logging for cost tracking
615
+ - [ ] Feature flag checked (`appConfig.features.ai`)
616
+ - [ ] Config integration with `appConfig.services.ai.provider`
617
+ - [ ] Tests written with mocked AI client