Skip to content

feat(gateway): add custom BYOK upstream routing primitives#3544

Open
baanish wants to merge 3 commits into
Kilo-Org:mainfrom
baanish:main
Open

feat(gateway): add custom BYOK upstream routing primitives#3544
baanish wants to merge 3 commits into
Kilo-Org:mainfrom
baanish:main

Conversation

@baanish
Copy link
Copy Markdown

@baanish baanish commented May 27, 2026

Motivation

  • Add Gateway support for user/org-registered OpenAI-compatible custom upstreams (BYOK-style) so authenticated users and orgs can route requests to their own providers using model ids shaped as <provider_id>/<upstream_model_id>.
  • Provide an MVP that implements the routing, model-rewrite, header injection, and BYOK flag plumbing while keeping the change surface small and safe for follow-ups.
  • Avoid exposing decrypted secrets to clients and preserve existing BYOK semantics for usage, metrics, and 402 handling.
  • Address [FEATURE]: Add Gateway support for BYOK custom OpenAI-compatible upstreams kilocode#10188

Description

  • Added a new DB table gateway_custom_upstreams with owner scoping (user/org), provider_id, display_name, base_url, encrypted_api_key, optional encrypted_extra_headers, model_metadata, is_enabled, timestamps, unique/index/check constraints, and a new GatewayCustomUpstream type. (packages/db/src/schema.ts)
  • Implemented getCustomUpstreamForModel which parses <provider_id>/<upstream_model_id> ids, resolves an enabled owner-scoped upstream row, decrypts the stored API key and optional headers, and returns a resolved upstream configuration. (apps/web/src/lib/ai-gateway/byok/custom-upstreams.ts)
  • Extended provider resolution (get-provider.ts) to detect authenticated owner-scoped custom upstreams before OpenRouter/Vercel fallbacks, return a provider entry that rewrites request.body.model to the upstream model id, merges optional extra headers, and surface an isByok boolean on the provider result. (apps/web/src/lib/ai-gateway/providers/get-provider.ts)
  • Updated gateway request handling to use isByok || !!userByok for abuse classification, usage context (user_byok), and OpenRouter 402 handling so custom upstreams are treated consistently with BYOK behavior. (apps/web/src/app/api/openrouter/[...path]/route.ts)
  • Extended the /models catalog router to include enabled custom upstream models (from model_metadata) for the authenticated user/org so those models appear in the catalog. (apps/web/src/routers/models-router.ts)
  • Kept scope intentionally minimal: added routing primitives and plumbing but did not implement the tRPC CRUD/list/test/discover endpoints, full header/URL validation guard rails, exhaustive tests, or migrations generation in this change.

Testing

  • Ran pnpm format which completed successfully.
  • Ran pnpm run lint which completed successfully.
  • Ran pnpm run typecheck; the typecheck run completed (with expected workspace rollup/external warnings) after fixing local type/duplicate-property edits introduced during development.
  • Attempted pnpm drizzle generate but it failed in this environment because POSTGRES_URL is not configured, so no migration file was produced and DB migration verification could not be completed here.
  • Ran pnpm run test; the test suite failed due to many unrelated test/db environment failures (test DB/cleanup queries and fetch mocks) in the local environment rather than the changes introduced here, so dedicated unit/integration tests for custom upstreams were not added in this PR.

Codex Task

Summary by CodeRabbit

  • New Features

    • Added configurable custom upstreams so users can register external model endpoints and API keys.
    • Custom upstream models now appear in model selection.
  • Improvements

    • Broader BYOK awareness across routing and usage reporting.
    • Upstream billing/402 responses are handled differently when BYOK is present.
  • Chores

    • Database migration to add storage for custom upstreams.

input?.organizationId
? eq(gateway_custom_upstreams.organization_id, input.organizationId)
: orgIds.length > 0
? inArray(gateway_custom_upstreams.organization_id, orgIds)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Logic bug — user-owned upstreams are silently dropped when the user belongs to any organization.

When input?.organizationId is not provided and orgIds.length > 0, the inArray condition filters only on organization_id, meaning a user who is a member of one or more orgs will never see their personal (kilo_user_id-scoped) custom upstreams in the catalog.

The intent is likely to include both org-owned upstreams and user-owned upstreams when no organizationId is specified. Consider using an or(...) that combines both.

.where(eq(organization_memberships.kilo_user_id, ctx.user.id));
const orgIds = orgMemberships.map(m => m.organization_id);

const upstreams = await db
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Over-fetching sensitive fields — the full select() pulls encrypted_api_key and encrypted_extra_headers which are not needed for the model catalog. Prefer selecting only the columns actually used (provider_id, model_metadata) to avoid unnecessarily loading encrypted key material into application memory on every catalog fetch.

try {
const apiKey = decryptApiKey(row.encrypted_api_key, BYOK_ENCRYPTION_KEY);
const extraHeaders = row.encrypted_extra_headers
? (JSON.parse(decryptApiKey(row.encrypted_extra_headers, BYOK_ENCRYPTION_KEY)) as Record<
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unsafe as cast on decrypted JSON — JSON.parse(...) is cast directly to Record<string, string> without validation. If the stored encrypted_extra_headers payload was not a flat string-to-string object (e.g. nested values, numbers, or null), header injection could silently pass malformed data to the upstream. Consider adding a runtime shape check or using a Zod schema here.

Comment thread packages/db/src/schema.ts

export type BYOKApiKey = typeof byok_api_keys.$inferSelect;

export const gateway_custom_upstreams = pgTable(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Missing soft-delete cleanup — gateway_custom_upstreams stores kilo_user_id and encrypted API keys (encrypted_api_key, encrypted_extra_headers). The ON DELETE CASCADE FK only fires when the parent kilocode_users row is hard-deleted. softDeleteUser in apps/web/src/lib/user/index.ts anonymizes the user row (it does not delete it), so the cascade never fires and encrypted credentials remain in this table indefinitely after a user is soft-deleted. Per the repo's GDPR rule, softDeleteUser must also tx.delete(gateway_custom_upstreams).where(eq(gateway_custom_upstreams.kilo_user_id, userId)) and a corresponding test must be added to apps/web/src/lib/user/index.test.ts.

@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented May 27, 2026

Code Review Summary

Status: 4 Issues Found | Recommendation: Address before merge

Executive Summary

Encrypted credentials stored in gateway_custom_upstreams will persist after user soft-delete due to a missing GDPR cleanup step, and user-owned upstreams are silently dropped from the model catalog when a user belongs to any organization.

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
apps/web/src/routers/models-router.ts 44 Logic bug — users with org memberships never see their personal user-owned upstreams in the catalog
apps/web/src/routers/models-router.ts 35 Full select() unnecessarily loads encrypted_api_key / encrypted_extra_headers on every catalog fetch
packages/db/src/schema.ts 3881 Missing soft-delete cleanup — gateway_custom_upstreams encrypted credentials persist after softDeleteUser because the FK cascade requires a hard-delete that never happens

SUGGESTION

File Line Issue
apps/web/src/lib/ai-gateway/byok/custom-upstreams.ts 40 Unsafe as cast on decrypted JSON for extraHeaders — no runtime shape validation
Other Observations (not in diff)
  • apps/web/src/lib/user/index.test.ts — Per repo GDPR rules, a test covering the new gateway_custom_upstreams deletion in softDeleteUser must be added alongside the cleanup fix.
  • The PR description explicitly notes that no tRPC CRUD endpoints, validation guard rails, or exhaustive tests were added. The routing primitive is safe enough as a behind-the-scenes building block, but the soft-delete gap is a real data-retention issue that should be addressed before this reaches production users.
Files Reviewed (8 files)
  • apps/web/src/app/api/openrouter/[...path]/route.ts — changes look correct; isByok || !!userByok propagation is consistent
  • apps/web/src/lib/ai-gateway/byok/custom-upstreams.ts — 1 issue
  • apps/web/src/lib/ai-gateway/providers/get-provider.ts — custom upstream check before existing BYOK paths looks correct; organization membership is pre-validated by auth layer
  • apps/web/src/routers/models-router.ts — 2 issues
  • packages/db/src/migrations/0145_nappy_iron_man.sql — migration DDL is correct; indexes, constraints, and FK cascade are appropriate
  • packages/db/src/migrations/meta/0145_snapshot.json — generated, not reviewed
  • packages/db/src/migrations/meta/_journal.json — generated, not reviewed
  • packages/db/src/schema.ts — 1 issue (soft-delete)

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-4.6 · 3,255,637 tokens

Review guidance: REVIEW.md from base branch main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant