@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.
- package/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- 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
|