nyxcore-systems
6 min read

Unpacking a Dev Session: User-Level BYOK, Docker Woes, and Prisma Puzzles

Join me as I pull back the curtain on a recent development session, tackling the complexities of user-level Bring Your Own Key (BYOK) for LLMs, wrestling with Docker, and refining our application's core architecture.

BYOKPrismaDockerTypeScripttRPCLLMsDevOpsArchitecture

Every development session is a journey. Sometimes it's a smooth cruise, other times it's a white-knuckle ride through unexpected challenges. Recently, I wrapped up a particularly dense session that perfectly encapsulated this dynamic. The primary goal was to roll out user-level Bring Your Own Key (BYOK) functionality for our LLM integrations, but as always, the path was paved with intriguing side quests and critical lessons learned.

This post isn't just a status update; it's a reflection on the decisions made, the problems solved, and the architectural patterns reinforced. Think of it as a peek into a developer's raw memory dump, cleaned up and distilled for public consumption.

The Core Mission: User-Level BYOK for LLMs

Our application leverages Large Language Models (LLMs) for various features, and a highly requested capability has been enabling users to "bring their own" API keys. This means moving beyond tenant-wide keys to allow individual users to configure their specific LLM provider credentials. This seemingly straightforward feature touched several critical parts of our backend.

1. Schema Evolution: isPersonal Arrives

The first step was to differentiate between tenant-wide and user-specific API keys. This was handled directly in our Prisma schema:

typescript
// prisma/schema.prisma
model ApiKey {
  id         String    @id @default(cuid())
  // ... other fields ...
  userId     String?   // Null for tenant-wide keys, present for personal keys
  isPersonal Boolean   @default(false) // New field to clearly distinguish
  // ...
}

Adding isPersonal with a default of false made it clear whether a key belonged to a specific user or was configured at the tenant level.

2. The user→tenant Fallback Pattern

This was the architectural cornerstone of the BYOK implementation. When resolving an LLM provider or a GitHub token, we needed a robust lookup mechanism: first, check for a user's personal key; if not found, fall back to the tenant's configured key.

This pattern was implemented in resolveProvider() for LLMs and resolveGitHubToken() for our GitHub connector. The key change was making userId an optional parameter in the lookup functions. If userId is provided, we query for personal keys first. If no personal key is found, or if userId is absent, we then query for a tenant-wide key.

We also introduced resolveProviderWithFallback(), which automatically incorporates this logic, ensuring that our LLM services always have a provider to work with, even if a user hasn't configured a personal key.

3. Admin Router Updates: Managing Keys

Our tRPC admin router needed to reflect these changes.

  • The apiKeys.list procedure now includes isPersonal in its select clause, allowing the UI to differentiate.
  • The apiKeys.create procedure was updated to accept an isPersonal boolean parameter. This allows administrators to create tenant-wide keys (default isPersonal: false) and, eventually, members to create their own personal keys (with isPersonal: true).

This ensures that the API layer correctly handles the creation and retrieval of both types of keys.

Enhancing User Experience & Content Generation

Beyond BYOK, a few other significant improvements landed:

  • Blog Truncation Fix: Our LLM-powered blog generation sometimes suffered from truncated content. The culprit? An overly conservative MAX_TOKENS limit. Bumping it from 4096 to 16384 means our LLMs can now generate more comprehensive and high-quality articles. This directly impacts the richness of our generated content.
  • Blog Redesign: On the frontend, the public blog (/b/[slug]) received a facelift. It now features a month-grouped timeline and a prominent hero card, enhancing readability and discovery.
  • First-Install Seed: Ensuring a smooth first-time setup is crucial. Our prisma/seed-init.ts script was refactored to be idempotent, accept CLI arguments, and now passes three critical tests, making initial deployments much more reliable.

The "Pain Log" Transformed: Lessons from the Trenches

No development session is complete without hitting a few snags. These aren't just "pains"; they're invaluable learning opportunities.

Lesson 1: The Docker Cache Monster Strikes Again!

The Problem: I tried to build our Docker image on our production server, and it failed with the dreaded no space left on device error. This is a classic.

The Fix: A quick and decisive prune of Docker's accumulated cruft:

bash
docker system prune -af && docker builder prune -af

This command reclaims space by removing unused Docker objects (containers, images, networks, volumes) and specifically targets build cache. In this instance, it reclaimed a whopping 73.36GB!

The Takeaway: Docker builds can accumulate a lot of cache over time, especially on build servers or production instances where you might be iteratively deploying. Regularly pruning your Docker system is essential for maintaining disk space and preventing unexpected build failures. Consider integrating this into your CI/CD cleanup steps or having a scheduled maintenance task.

Lesson 2: Prisma Schema Changes: Don't Forget prisma generate!

The Problem: After adding the isPersonal field to our ApiKey model in prisma/schema.prisma, my TypeScript type checks started failing. The error message was clear: "isPersonal does not exist in type ApiKeyWhereInput."

The Fix: This is a common oversight when working with Prisma. After any schema change, you must regenerate the Prisma client:

bash
npx prisma generate

The Takeaway: Prisma's client (@prisma/client) is automatically generated based on your schema.prisma file. When you modify the schema, the generated client becomes out of sync with your new definitions. Always remember to run npx prisma generate after making schema changes to update the client and ensure your TypeScript types (and runtime queries) are correct.

What's Next? The Road Ahead

While a lot was accomplished, the dev journey continues. Immediate next steps include:

  • Committing the BYOK changes and pushing the schema to production.
  • Translating crucial German "Ipcha Mistabra" documents into academia-ready English.
  • Designing and implementing a "Book Key Points" feature.
  • Adding a {{ethics}} template variable for our content generation, ensuring responsible AI outputs.

This session was a fantastic example of the multifaceted nature of software development. From high-level architectural decisions and schema changes to battling Docker and remembering Prisma commands, it's all part of building robust, user-friendly systems. Here's to the next session and the lessons it will bring!


json
{"thingsDone":[
  "Implemented User-Level BYOK schema (`isPersonal` field)",
  "Rewrote `resolveProvider()` and `resolveGitHubToken()` with `user→tenant` fallback",
  "Added `resolveProviderWithFallback()` for automatic tenant fallback",
  "Updated Admin router (`apiKeys.list`, `apiKeys.create`) for `isPersonal` support",
  "Fixed blog truncation by increasing `MAX_TOKENS` to 16384",
  "Redesigned public blog page with month-grouped timeline and hero card",
  "Improved first-install seed script (idempotent, CLI args, passing tests)"
],
"pains":[
  "Docker build failed with 'no space left on device' on production",
  "Prisma typecheck error 'isPersonal does not exist' after schema change"
],
"successes":[
  "Reclaimed 73.36GB on production server with Docker prune",
  "Resolved Prisma type error by running `npx prisma generate`"
],
"techStack":[
  "Prisma",
  "TypeScript",
  "Docker",
  "tRPC",
  "LLMs",
  "Next.js",
  "Git",
  "Linux"
]}