Design Decisions
This document explains the reasoning behind the major technical decisions made when building AllMyChats. Understanding these decisions will help you navigate the codebase and make informed choices when extending the application.
Table of Contents
- Why TanStack Start
- Why tRPC
- Why PostgreSQL + Drizzle ORM
- Why Better Auth
- LLM-Agnostic Architecture
- File Storage Strategy (R2/S3)
- Layered Architecture Pattern
Why TanStack Start
What is TanStack Start?
TanStack Start is a modern full-stack React framework that combines the best of React Server Components, file-based routing, and powerful data fetching through TanStack Query.
Key Benefits
1. Type Safety Across the Stack
// Define a server function with full type safety
export const getMessageFn = createServerFn({ method: "GET" })
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
return await getMessage(data.id);
});
// Call it from the client with autocomplete and type checking
const message = await getMessageFn({ id: "123" });
// TypeScript knows message is of type Message
Why this matters: End-to-end type safety prevents bugs at compile time rather than runtime. When you change a server function's signature, TypeScript will immediately show all client locations that need updating.
2. Unified Data Fetching
TanStack Start integrates TanStack Query deeply, providing:
- Automatic caching and invalidation
- Optimistic updates
- Background refetching
- Loading and error states
// Data fetching with automatic caching
const { data: messages, isLoading } = useQuery({
queryKey: ["messages"],
queryFn: getMessagesFn,
});
Why this matters: Reduces boilerplate code and provides a consistent pattern for data fetching across the entire application.
3. File-Based Routing
src/routes/
├── index.tsx → /
├── dashboard.tsx → /dashboard
├── messages/
│ ├── $id.tsx → /messages/:id
│ └── index.tsx → /messages
└── api/
└── trpc/
└── $.ts → /api/trpc/*
Why this matters: Clear organization, collocated routing logic, and automatic route generation make the codebase easier to navigate and maintain.
4. Server and Client Components
TanStack Start supports both Server Components (for performance) and Client Components (for interactivity) in the same application:
// Server component - runs on the server, reduces client bundle
export const MessageList = async () => {
const messages = await getMessagesFn();
return <div>{/* render messages */}</div>;
};
// Client component - runs on the client for interactivity
export const MessageFilter = () => {
const [filter, setFilter] = useState("");
// ... interactive logic
};
Why this matters: Optimize performance by rendering data-heavy components on the server while keeping interactive components on the client.
Alternatives Considered
| Framework | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| Next.js | Mature, large community | Complex app dir, less flexible routing | TanStack Start offers better TypeScript integration and simpler mental model |
| Remix | Web standards focused | Less built-in caching, verbose | TanStack Start provides better data fetching out of the box |
| Vite + React Router | Simple, fast | Manual server setup needed | TanStack Start provides full-stack solution in one package |
Why tRPC
What is tRPC?
tRPC (TypeScript Remote Procedure Call) is a library that allows you to build fully typesafe APIs without schemas or code generation. It uses TypeScript's type inference to share types between client and server automatically.
Key Benefits
1. End-to-End Type Safety Without Schema Duplication
// Define router on server
const messageRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().optional() }))
.query(async ({ input }) => {
return await getMessages(input.limit);
}),
});
// Use on client with full type inference
const { data } = trpc.messages.list.useQuery({ limit: 10 });
// TypeScript knows the exact shape of data
Why this matters: When you change the server procedure, TypeScript immediately flags all client call sites that need updating. No separate API contracts or Swagger schemas to maintain.
2. Collocated Server and Client Code
tRPC procedures are organized alongside your business logic:
src/server/routers/
├── messages.ts // Message-related procedures
├── conversations.ts // Conversation procedures
└── categories.ts // Category procedures
Why this matters: Keep related code together, making it easier to understand and modify features.
3. Automatic Type Inference
export const appRouter = router({
messages: messageRouter,
conversations: conversationRouter,
categories: categoryRouter,
});
export type AppRouter = typeof appRouter;
// Client has full knowledge of all procedures
const trpc = createTRPCClient<AppRouter>({
/* ... */
});
Why this matters: No manual type exports or imports. The client automatically knows about all available procedures.
4. Batteries Included
tRPC includes:
- Built-in error handling
- Request batching
- Loading and error states via React Query integration
- Middleware for authentication and validation
const protectedProcedure = t.procedure
.use(authMiddleware) // Reusable auth middleware
.input(
z.object({
/* validation schema */
})
);
export const messageRouter = router({
create: protectedProcedure.mutation(async ({ input, ctx }) => {
// ctx.userId is available from authMiddleware
return await createMessage(input, ctx.userId);
}),
});
Why this matters: Less boilerplate to write and maintain.
Alternatives Considered
| Approach | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| REST + OpenAPI | Standard, language-agnostic | Type duplication, manual sync | tRPC eliminates type drift between client and server |
| GraphQL + Apollo | Flexible queries, single endpoint | Complex setup, over-fetching control | tRPC provides same type safety with less complexity |
| Server Functions Only | Simple, no RPC layer | Less structure, manual type sharing | tRPC provides better organization and automatic type inference |
Why PostgreSQL + Drizzle ORM
What is PostgreSQL?
PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.
What is Drizzle ORM?
Drizzle ORM is a TypeScript ORM that provides type-safe database queries with a minimal API and excellent performance.
Key Benefits
1. Type-Safe Queries
import { message, category } from "~/db/schema";
import { eq, and } from "drizzle-orm";
// Fully typed query
const messages = await database
.select()
.from(message)
.leftJoin(category, eq(message.categoryId, category.id))
.where(eq(message.userId, userId));
// TypeScript knows messages is of type MessageWithCategory[]
Why this matters: Catch query errors at compile time, not runtime. The query result type is automatically inferred.
2. Schema as Single Source of Truth
// Define schema once
export const message = pgTable("message", {
id: text("id").primaryKey(),
content: text("content").notNull(),
sender: text("sender").notNull(),
timestamp: timestamp("timestamp").notNull(),
categoryId: text("category_id").references(() => category.id),
// ...
});
// Use the schema to generate migrations
npm run db:generate
// And TypeScript types
export type Message = typeof message.$inferSelect;
Why this matters: Your database schema is defined in code, making it version-controlled and type-safe. Migrations are generated automatically from schema changes.
3. Performance
Drizzle ORM generates optimized SQL queries without heavy runtime overhead:
// Drizzle generates this SQL:
// SELECT id, content, sender, timestamp FROM message
// WHERE user_id = $1 ORDER BY timestamp DESC LIMIT 50
const recentMessages = await database
.select()
.from(message)
.where(eq(message.userId, userId))
.orderBy(desc(message.timestamp))
.limit(50);
Why this matters: Fast queries without sacrificing type safety. Drizzle doesn't use a query builder that adds runtime overhead.
4. PostgreSQL Features
PostgreSQL provides powerful features out of the box:
- Full-text search: Built-in search capabilities for messages
- JSONB: Store flexible data (message metadata, LLM responses)
- Array types: Store tags, categories
- Indexing: Optimize query performance
- Transactions: ACID guarantees for data consistency
// Full-text search example
const results = await database
.select()
.from(message)
.where(sql`${message.content} ILIKE ${`%${searchTerm}%`}`);
Why this matters: PostgreSQL's advanced features reduce the need for external services (e.g., Elasticsearch for search).
5. Migration Management
# Generate migration from schema changes
npm run db:generate
# Apply migrations
npm run db:migrate
# Open Drizzle Studio to inspect data
npm run db:studio
Why this matters: Simple, version-controlled migrations. No separate migration files to maintain manually.
Alternatives Considered
| Database/ORM | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| Prisma | Excellent DX, mature | Heavy runtime, less flexible queries | Drizzle provides better performance and more control |
| TypeORM | Decorator-based, popular | Less type-safe, heavier | Drizzle has better TypeScript integration |
| MongoDB + Mongoose | Flexible schema | No ACID, less powerful queries | PostgreSQL provides better data integrity and querying |
| SQLite | Simple, embedded | Limited concurrency | PostgreSQL is more production-ready for multi-user app |
Why Better Auth
What is Better Auth?
Better Auth is a modern authentication library for TypeScript that provides secure, type-safe authentication with minimal setup.
Key Benefits
1. Type Safety
// Auth instance is fully typed
export const auth = betterAuth({
database: drizzleAdapter(database, { provider: "pg" }),
emailAndPassword: { enabled: true },
});
// Session is typed
const session = await auth.api.getSession({ headers: request.headers });
if (session) {
// TypeScript knows session.user has id, email, name, etc.
console.log(session.user.email);
}
Why this matters: Auth-related code is fully type-safe, reducing bugs and improving developer experience.
2. Simple Setup
// src/utils/auth.ts
export const auth = betterAuth({
database: drizzleAdapter(database, { provider: "pg" }),
emailAndPassword: { enabled: true },
});
// src/routes/api/auth/$.ts
export const Route = createServerFileRoute("/api/auth/$").methods({
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
});
Why this matters: Authentication is set up in minutes, not hours. No complex configuration needed.
3. Security by Default
Better Auth handles:
- Password hashing with bcrypt
- Session management with secure cookies
- CSRF protection
- SQL injection protection (via Drizzle)
- Rate limiting (configurable)
Why this matters: Authentication security is handled by experts. Don't roll your own auth security.
4. Flexible Auth Middleware
// Reusable auth middleware for tRPC
const enforceUserIsAuthenticated = t.middleware(async ({ ctx, next }) => {
if (!ctx.isAuthenticated || !ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
userId: ctx.userId,
isAuthenticated: true as const,
},
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthenticated);
Why this matters: Consistent authentication patterns across all API routes.
5. Database Integration
Better Auth integrates seamlessly with Drizzle ORM:
export const user = pgTable("user", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
// ... custom fields
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => user.id),
expiresAt: timestamp("expires_at").notNull(),
// ...
});
Why this matters: Auth tables are part of your database schema, fully typed and manageable through migrations.
Alternatives Considered
| Auth Library | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| NextAuth.js | Popular, many providers | Next.js-specific, complex setup | Better Auth is framework-agnostic and simpler |
| Auth.js | Modular, flexible | Steeper learning curve | Better Auth provides better DX for our use case |
| Lucia | Lightweight, flexible | More manual setup | Better Auth provides more out-of-the-box features |
| Custom Auth | Full control | Security risks, time-consuming | Don't roll your own auth security |
LLM-Agnostic Architecture
What is LLM-Agnostic Design?
LLM-agnostic design means building AI features that work with any LLM provider (Claude, OpenAI, GLM, etc.) without being tied to a specific service.
Key Benefits
1. Provider Flexibility
// Define generic types
export interface LLMRequestOptions {
provider?: string; // "claude", "openai", "glm", etc.
model?: string;
temperature?: number;
maxTokens?: number;
}
// Functions accept any provider
export async function categorizeMessage(
message: string,
options: LLMRequestOptions = {}
): Promise<CategorizationResult> {
const client = options.llm || defaultClient;
// Works with any provider
return await client.query(prompt, options);
}
Why this matters: Switch providers without rewriting code. Take advantage of new models or pricing without architectural changes.
2. Cost Optimization
// Choose provider based on use case
const quickCategorization = await categorizeMessage(msg, {
provider: "openai", // Faster, cheaper
model: "gpt-4o-mini",
});
const deepAnalysis = await analyzeMessage(msg, {
provider: "claude", // Better reasoning
model: "claude-3-5-sonnet-20241022",
});
Why this matters: Use the right tool for the job. Save costs with cheaper models for simple tasks, premium models for complex analysis.
3. Unified Client Interface
// All providers implement the same interface
export interface LLMClient {
query(prompt: string, options?: LLMRequestOptions): Promise<LLMResponse>;
stream(prompt: string, options?: LLMRequestOptions): AsyncIterable<LLMStreamChunk>;
}
// Add new providers without changing application code
class GLMProvider implements BaseLLMProvider {
/* ... */
}
class GeminiProvider implements BaseLLMProvider {
/* ... */
}
Why this matters: Add new AI providers by implementing a single interface. No changes to application code.
4. Prompt Reusability
// Prompts work with any provider
export function createCategorizationPrompt(message: string, categories: Category[]): string {
return `
You are an expert message categorization assistant.
Task: Categorize this message into ONE of the following categories:
${categories.map((c) => `- ${c.name}: ${c.description}`).join("\n")}
Response Format (JSON only):
{
"category": "category name",
"confidence": 0.0 to 1.0,
"reasoning": "brief explanation"
}
Message: "${message}"
`;
}
Why this matters: Write prompts once, use with any provider. Provider-specific features are minimal and isolated.
5. Error Handling Abstraction
// Standardized error types
export class LLMError extends Error {
constructor(
message: string,
public provider?: string,
public model?: string,
public cause?: Error
) {
super(message);
this.name = "LLMError";
}
}
// Provider-agnostic error handling
try {
return await llm.query(prompt, options);
} catch (error) {
if (isRateLimitError(error)) {
// Handle rate limit consistently across providers
return await retryWithFallback(prompt, options);
}
throw new LLMError("LLM request failed", options.provider, options.model, error);
}
Why this matters: Consistent error handling regardless of provider. Easier debugging and monitoring.
Implementation in AllMyChats
// src/lib/llm/index.ts
export { LLMClient, createLLMClient } from "./client";
export { ClaudeProvider, OpenAIProvider, GLMProvider } from "./providers";
// Usage in features
import { llm } from "~/lib/llm";
const result = await llm.query(categorizationPrompt, {
provider: "claude", // Configurable via environment
model: "claude-3-5-sonnet-20241022",
temperature: 0.3,
});
Alternatives Considered
| Approach | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| Provider-specific | Uses provider best features | Locked in, hard to switch | Limits flexibility and negotiating power |
| LangChain | Rich ecosystem | Heavy, complex, opinionated | Custom abstraction is lighter and more flexible |
| Vercel AI SDK | Good DX, streaming | Vercel-focused | Generic approach works better for our needs |
Documentation
See LLM-Agnostic Development Guide for complete implementation guidelines and best practices.
File Storage Strategy (R2/S3)
What is Cloudflare R2?
Cloudflare R2 is an S3-compatible object storage service with zero egress fees. It's designed for storing and serving large files like images, videos, and audio.
Architecture: Presigned URLs
AllMyChats uses a presigned URL approach for file uploads:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ ──(1) Request──▶ │ Server │ ──(2) Generate──▶ R2 │
│ │ Upload URL │ │ Presigned URL │
│ │ ◀──(3) Return─── │ │ ◀──(4) Return────────── │
│ │ Upload URL │ │ Presigned URL │
└─────────┘ └─────────┘ └─────────┘
│ │
│ ────(5) Upload File Directly to R2 ─────────────────▶
│ (using presigned URL)
Key Benefits
1. Reduced Server Load
Files are uploaded directly from client to R2, bypassing the server:
// Server: Generate presigned URL (lightweight)
const uploadUrl = await r2.getPresignedUploadUrl(key, contentType);
// Client: Upload directly to R2
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
});
Why this matters: Server doesn't handle large file payloads. Reduces memory usage and improves scalability.
2. Client-Side Progress Tracking
Direct uploads enable real-time progress tracking:
export async function uploadFileWithProgress(
file: File,
url: string,
onProgress: (progress: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100);
}
});
xhr.addEventListener("load", () => resolve());
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.open("PUT", url);
xhr.send(file);
});
}
Why this matters: Better UX with real-time upload progress bars.
3. Controlled Access
Server controls who can upload and what they can upload:
// Protected upload endpoint
export const getUploadUrlFn = createServerFn({ method: "POST" })
.middleware([authenticatedMiddleware]) // Require auth
.inputValidator(
z.object({
contentType: z.string(),
fileSize: z.number(),
})
)
.handler(async ({ input, context }) => {
// Validate file type and size
if (!ALLOWED_TYPES.includes(input.contentType)) {
throw new Error("Invalid file type");
}
if (input.fileSize > MAX_SIZE) {
throw new Error("File too large");
}
// Generate user-specific upload path
const key = `uploads/${context.userId}/${nanoid()}`;
return {
uploadUrl: await r2.getPresignedUploadUrl(key, input.contentType),
key,
};
});
Why this matters: Centralized validation and authorization. Users can only upload to their designated locations.
4. Scalability
R2/S3 handles unlimited file storage and bandwidth:
// Storage structure
bucket/
├── music/
│ └── {userId}/
│ └── {songId}/
│ ├── audio.{ext}
│ └── cover.{ext}
├── profile-images/
│ └── {userId}/
│ └── {timestamp}.{ext}
└── uploads/
└── {userId}/
└── {timestamp}.{ext}
Why this matters: No need to provision storage or manage disk space. Scales automatically with user base.
5. Cost Efficiency
Cloudflare R2 has zero egress fees, making it cost-effective for serving files:
| Operation | S3 (us-east-1) | Cloudflare R2 |
|---|---|---|
| Storage | $0.023/GB | $0.015/GB |
| Egress | $0.09/GB | $0.00/GB ✅ |
| Class A Operations | $0.005/1K requests | $4.50/M requests |
Why this matters: Serving media files (audio, images) doesn't incur bandwidth costs, significantly reducing monthly bills.
Alternatives Considered
| Storage Method | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| Server disk storage | Simple, fast | Limited space, backups needed | Doesn't scale, single point of failure |
| AWS S3 | Mature, feature-rich | High egress fees | R2 provides same features at lower cost |
| Cloudflare Images | Optimized images | Images only | Need to store multiple file types |
| Google Cloud Storage | Good integration | Egress fees | R2 is more cost-effective |
Documentation
See the project's file-uploads documentation for complete implementation details.
Layered Architecture Pattern
What is Layered Architecture?
Layered architecture organizes code into distinct layers with clear responsibilities. Each layer only depends on the layer below it.
Architecture Layers
┌─────────────────────────────────────────────────────────────┐
│ 1. Routes Layer (src/routes/) │
│ - URL routing, route loaders, page composition │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. Components Layer (src/components/) │
│ - UI rendering, user interactions, local state │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. Hooks Layer (src/hooks/) │
│ - State management, data fetching, reusable logic │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. Queries Layer (src/queries/) │
│ - TanStack Query configs, caching, invalidation │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. Server Functions Layer (src/server/routers/) │
│ - tRPC procedures, validation, auth, business logic │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 6. Data Access Layer (src/data-access/) │
│ - Database operations, transactions, CRUD │
└─────────────────────────────────────────────────────────────┘
Key Benefits
1. Separation of Concerns
Each layer has a single, clear responsibility:
// Route: Only handles routing
export const Route = createFileRoute("/messages/$id")({
component: MessagePage,
});
// Component: Only handles UI
function MessagePage() {
const { data: message } = useMessage(); // Delegate to hook
return <MessageDisplay message={message} />;
}
// Hook: Manages state and data fetching
function useMessage(id: string) {
return trpc.messages.get.useQuery({ id }); // Delegate to tRPC
}
// tRPC: Handles API endpoint
const messagesRouter = router({
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await getMessageById(input.id); // Delegate to data access
}),
});
// Data Access: Database operations
export async function getMessageById(id: string) {
const [message] = await database
.select()
.from(messageTable)
.where(eq(messageTable.id, id));
return message;
}
Why this matters: Each piece of code has a clear job. Easier to understand, test, and modify.
2. Testability
Each layer can be tested independently:
// Test data access without server
describe("getMessageById", () => {
it("returns message by ID", async () => {
const message = await getMessageById("123");
expect(message).toBeDefined();
expect(message.id).toBe("123");
});
});
// Test tRPC procedure with mocked data access
describe("messages.get", () => {
it("returns message", async () => {
vi.mock("~/data-access/messages", () => ({
getMessageById: vi.fn().mockResolvedValue(mockMessage),
}));
const result = await messagesRouter.get.get({ input: { id: "123" } });
expect(result).toEqual(mockMessage);
});
});
// Test component with mocked hook
describe("MessagePage", () => {
it("renders message", () => {
vi.mock("~/hooks/useMessage", () => ({
useMessage: vi.fn().mockReturnValue({ data: mockMessage }),
}));
render(<MessagePage />);
expect(screen.getByText(mockMessage.content)).toBeInTheDocument();
});
});
Why this matters: Easier to write tests. Mock lower layers to test upper layers in isolation.
3. Reusability
Code in lower layers can be reused across multiple routes:
// Data access function used by multiple tRPC procedures
export async function findMessagesByUser(userId: string) {
return await database.select().from(messageTable).where(eq(messageTable.userId, userId));
}
// Used in multiple routers
const messagesRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
return findMessagesByUser(ctx.userId);
}),
});
const searchRouter = router({
messages: publicProcedure.query(async ({ ctx }) => {
return findMessagesByUser(ctx.userId);
}),
});
Why this matters: Less code duplication. Changes to business logic only need to be made in one place.
4. Dependency Direction
Dependencies flow in one direction (downwards):
Routes → Components → Hooks → Queries → Server → Data Access
↓ ↓ ↓ ↓ ↓ ↓
No No No No No No
circular circular circular circular circular dependencies
Why this matters: Prevents circular dependencies and coupling. Easier to refactor and maintain.
5. Easy to Navigate
Clear folder structure makes finding code easy:
src/
├── routes/ # "Where is the /messages page?" → routes/messages.tsx
├── components/ # "Where is the MessageCard component?" → components/MessageCard.tsx
├── hooks/ # "Where is the useMessages hook?" → hooks/useMessages.ts
├── server/routers/ # "Where is the messages API?" → server/routers/messages.ts
└── data-access/ # "Where is the getMessages DB query?" → data-access/messages.ts
Why this matters: New developers can quickly understand the codebase structure.
Communication Rules
- Never skip layers: Components shouldn't call data access directly
- No circular dependencies: Lower layers never import from upper layers
- Single responsibility: Each layer has one clear purpose
- Error handling: Each layer handles errors appropriate to its level
Documentation
See Architecture Guide for complete details on the layered architecture pattern.
Summary of Technical Decisions
| Decision | Primary Benefit | Trade-off |
|---|---|---|
| TanStack Start | End-to-end type safety, unified DX | Newer framework, smaller community |
| tRPC | Type-safe API without schema duplication | Less standard than REST |
| PostgreSQL + Drizzle | Type-safe queries, powerful features | PostgreSQL required (not SQLite) |
| Better Auth | Simple, type-safe auth | Newer library than NextAuth |
| LLM-Agnostic | Provider flexibility, cost optimization | More abstraction layer |
| R2 Storage | Zero egress fees, scalable | Cloudflare-specific (can switch to S3) |
| Layered Architecture | Clear separation, testability | More files, deeper folder structure |
These decisions prioritize developer experience, type safety, and scalability while keeping the codebase maintainable and flexible for future growth.
Related Documentation
- Architecture Guide - Detailed layered architecture explanation
- LLM-Agnostic Guide - LLM provider patterns
Questions or Suggestions?
If you have questions about these design decisions or suggestions for improvements, please open an issue on GitHub.