Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ pnpm drizzle:verify-bootstrap
pnpm dev:start
```

This generates the local environment files and launches a tmux dashboard with the Next.js app and local infrastructure. It does not run database migrations.
This generates the local environment files and launches a tmux dashboard with the Next.js web app, the standalone AI gateway app, and local infrastructure. The web app proxies gateway routes to the local gateway on port 3010 plus the worktree port offset. It does not run database migrations.

`pnpm --filter web dev` also starts the gateway, defaulting to the selected web port plus 10. Set `AI_GATEWAY_PORT` to override that port. Stopping the command stops both apps; if either app exits, the other is stopped too. The managed `dev:start` stack continues to run the gateway as a separate service, without starting a duplicate from the web launcher.
Port offset selection is automatic by default, keeping secondary worktrees from colliding with the root checkout. This setup order assumes no port conflicts; if startup selects a different offset, run `pnpm test:db` again in another terminal in the same worktree.
When the Stripe CLI is installed, the command also starts the Stripe webhook forwarder. Run `pnpm dev:status` to get the web app's port.

Expand All @@ -248,7 +250,6 @@ should pass against the local PostgreSQL database.
|---|---|
| `KILO_PORT_OFFSET=auto pnpm dev:start` | Start all local services in a tmux dashboard with worktree-safe ports |
| `pnpm dev:start agents cloud-agent-public-tunnels` | Same as `dev:start agents`, plus Cloudflare quick tunnels for sandbox-facing Worker/Next/session-ingest URLs |
| `pnpm dev:start ai-gateway` | Also start the standalone AI gateway app (`apps/ai-gateway`) on port 3010 plus the port offset |
| `pnpm dev:stop` | Stop the tmux session and all services |
| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) |
| `pnpm dev:env --missing-secrets-only` | Sync env files and create missing local Secrets Store entries without refreshing existing secrets |
Expand Down
7 changes: 5 additions & 2 deletions apps/ai-gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ Next.js app that serves the AI gateway API on its own. It deploys to the
`packages/web-shared/AGENTS.md`.
- Handlers that depend on the path accept both apps' paths, such as the LLM
proxy's path validation.
- `pnpm dev` runs with the web app's environment files. Start it with
`pnpm dev:start ai-gateway`.
- `pnpm dev` runs with the web app's environment files. `pnpm dev:start` starts
it automatically as a dependency of the web app, on port 3010 plus the worktree
port offset. The web app rewrites gateway requests to it in development and to
`https://ai-gateway.kilo.ai` in production or `https://staging-ai-gateway.kilo.ai`
when `VERCEL_TARGET_ENV=staging`, on both global and non-global backends.
- The scheduled `deploy-production.yml` and `deploy-staging.yml` deploy this
app with the web apps, from the same CI-green commit, after migrations. A
release is only recorded as complete once the gateway is live too.
Expand Down
45 changes: 45 additions & 0 deletions apps/web/ai-gateway-rewrites.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export function getAiGatewayRewrites(env = process.env) {
const origin =
env.NODE_ENV === 'development'
? `http://localhost:${env.AI_GATEWAY_PORT || 3010 + Number(env.KILO_PORT_OFFSET || 0)}`
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
: env.VERCEL_TARGET_ENV === 'staging'
? 'https://staging-ai-gateway.kilo.ai'
: env.VERCEL_ENV === 'production'
? 'https://ai-gateway.kilo.ai'
: undefined;

if (!origin) return [];

return [
{
source: '/api/gateway/typesafe/v1/systemone',
destination: `${origin}/api/v1/systemone`,
},
...['gateway', 'openrouter'].flatMap(prefix => [
{
source: `/api/${prefix}/v1/:path*`,
destination: `${origin}/api/v1/:path*`,
},
{
source: `/api/${prefix}/:path*`,
destination: `${origin}/api/v1/:path*`,
},
]),
{
source: '/api/fim/completions',
destination: `${origin}/api/v1/fim/completions`,
},
{
source: '/api/edit/completions',
destination: `${origin}/api/v1/edit/completions`,
},
{
source: '/api/organizations/:id/models',
destination: `${origin}/api/v1/organizations/:id/models`,
},
{
source: '/api/organizations/:id/models/validate',
destination: `${origin}/api/v1/organizations/:id/models/validate`,
},
];
}
26 changes: 6 additions & 20 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import createMDX from '@next/mdx';
import { statSync } from 'fs';
import { resolve } from 'path';
import NextBundleAnalyzer from '@next/bundle-analyzer';
import { getAiGatewayRewrites } from './ai-gateway-rewrites.mjs';

const withBundleAnalyzer = NextBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
Expand Down Expand Up @@ -63,29 +64,14 @@ const nextConfig = {
// errors are still surfaced without the indicator.
devIndicators: false,

experimental: {
proxyTimeout: 800_000,
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
},

async rewrites() {
// Global API rewrites - proxy to global-api.kilo.ai when not on global backend
// Uses beforeFiles to ensure the rewrite happens BEFORE filesystem routes are checked
// See: https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites
const globalApiRewrites =
process.env.VERCEL_ENV === 'production' && process.env.GLOBAL_KILO_BACKEND !== 'true'
? [
{
source: '/api/fim/completions',
destination: 'https://global-api.kilo.ai/api/fim/completions',
},
{
source: '/api/edit/completions',
destination: 'https://global-api.kilo.ai/api/edit/completions',
},
{
source: '/api/gateway/:path*',
destination: 'https://global-api.kilo.ai/api/gateway/:path*',
},
{
source: '/api/openrouter/:path*',
destination: 'https://global-api.kilo.ai/api/openrouter/:path*',
},
{
source: '/api/exa/:path*',
destination: 'https://global-api.kilo.ai/api/exa/:path*',
Expand All @@ -98,7 +84,7 @@ const nextConfig = {
: [];

return {
beforeFiles: globalApiRewrites,
beforeFiles: [...getAiGatewayRewrites(), ...globalApiRewrites],
afterFiles: [
// /config.json is handled by src/app/config.json/route.ts which merges
// Kilo-specific schema additions on top of the upstream opencode schema.
Expand Down
54 changes: 34 additions & 20 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,20 @@ const e2eEnv = Object.fromEntries(
);

const port = process.env.PORT ? Number(process.env.PORT) : 3000;
const gatewayPort = process.env.AI_GATEWAY_PORT ? Number(process.env.AI_GATEWAY_PORT) : port + 10;
// Use localhost instead of 127.0.0.1 to match cookie domain
const baseURL = `http://localhost:${port}`;
const webServerEnv = {
...e2eEnv,
NODE_ENV: 'development',
DEBUG_SHOW_DEV_UI: 'true',
APP_URL_OVERRIDE: baseURL,
NEXTAUTH_URL: baseURL,
PORT: String(port),
AI_GATEWAY_PORT: String(gatewayPort),
VERCEL_ENV: '',
VERCEL_TARGET_ENV: '',
};

/**
* See https://playwright.dev/docs/test-configuration.
Expand Down Expand Up @@ -71,25 +83,27 @@ export default defineConfig({
},
],

/* Run your local dev server before starting the tests */
webServer: {
// Always use dev mode for Playwright tests - never production
command: `pnpm run copy:swagger-ui-assets && pnpm next dev -p ${port}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
env: {
...e2eEnv,
// Always use development mode for Playwright tests
NODE_ENV: 'development',
DEBUG_SHOW_DEV_UI: 'true', // Enable fake login
APP_URL_OVERRIDE: baseURL,
NEXTAUTH_URL: baseURL,
PORT: String(port),
VERCEL_ENV: '',
VERCEL_TARGET_ENV: '',
webServer: [
{
name: 'ai-gateway',
command: `pnpm --filter ai-gateway exec next dev -p ${gatewayPort}`,
// Playwright accepts 401 as ready, without needing model-provider access.
url: `http://localhost:${gatewayPort}/api/v1/organizations/playwright/models`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
env: webServerEnv,
},
},
{
name: 'web',
command: `pnpm run copy:swagger-ui-assets && pnpm next dev -p ${port}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
env: webServerEnv,
},
],
});
44 changes: 44 additions & 0 deletions apps/web/tests/e2e/ai-gateway-rewrites.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';

test.use({ storageState: { cookies: [], origins: [] } });

test('starts the standalone gateway with the Playwright servers', async ({ request, baseURL }) => {
if (!baseURL) throw new Error('Playwright baseURL is required');
const gatewayPort = process.env.AI_GATEWAY_PORT || Number(new URL(baseURL).port) + 10;
const response = await request.post(`http://localhost:${gatewayPort}/api/v1/fim/completions`, {
data: {},
});

expect(response.status()).toBe(401);
expect(response.headers()['content-type']).toContain('application/json');
});

for (const path of ['/api/fim/completions', '/api/edit/completions']) {
test(`proxies ${path} to the gateway`, async ({ request }) => {
const response = await request.post(path, { data: {} });

expect(response.status()).toBe(401);
expect(response.headers()['content-type']).toContain('application/json');
});
}

test('proxies organization model requests without changing authentication', async ({ request }) => {
const response = await request.get('/api/organizations/playwright/models');

expect(response.status()).toBe(401);
expect(response.headers()['content-type']).toContain('application/json');
});

test('serves a gateway response to browser requests through the web app', async ({ page }) => {
await page.goto('/users/sign_in');
const status = await page.evaluate(async () => {
const response = await fetch('/api/fim/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
return response.status;
});

expect(status).toBe(401);
});
144 changes: 144 additions & 0 deletions dev/local/ai-gateway-rewrites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import test from 'node:test';

import { getAiGatewayRewrites } from '../../apps/web/ai-gateway-rewrites.mjs';

const require = createRequire(new URL('../../apps/web/next.config.mjs', import.meta.url));
const { getPathMatch } = require('next/dist/shared/lib/router/utils/path-match');
const { prepareDestination } = require('next/dist/shared/lib/router/utils/prepare-destination');

function rewrite(url: string, env: NodeJS.ProcessEnv) {
const request = new URL(url, 'http://localhost');
for (const rule of getAiGatewayRewrites(env)) {
const params = getPathMatch(rule.source)(request.pathname);
if (params) {
return prepareDestination({
destination: rule.destination,
params,
query: Object.fromEntries(request.searchParams),
appendParamsToQuery: true,
}).parsedDestination;
}
}
return undefined;
}

const routes = [
...['gateway', 'openrouter'].flatMap(prefix => [
...['chat/completions', 'responses', 'messages', 'audio/transcriptions', 'transcription-models']
.flatMap(endpoint => [endpoint, `v1/${endpoint}`])
.map(endpoint => [`/api/${prefix}/${endpoint}`, `/api/v1/${endpoint.replace(/^v1\//, '')}`]),
...['models-by-provider', 'providers', 'embeddings'].map(endpoint => [
`/api/${prefix}/${endpoint}`,
`/api/v1/${endpoint}`,
]),
...['models', 'v1/models'].map(endpoint => [
`/api/${prefix}/${endpoint}/anthropic/claude-sonnet-4/endpoints`,
'/api/v1/models/anthropic/claude-sonnet-4/endpoints',
]),
[`/api/${prefix}/models`, '/api/v1/models'],
]),
['/api/gateway/v1/models', '/api/v1/models'],
['/api/gateway/embedding-models', '/api/v1/embedding-models'],
['/api/openrouter/models/validate', '/api/v1/models/validate'],
['/api/gateway/typesafe/v1/systemone', '/api/v1/systemone'],
['/api/fim/completions', '/api/v1/fim/completions'],
['/api/edit/completions', '/api/v1/edit/completions'],
['/api/organizations/org-123/models', '/api/v1/organizations/org-123/models'],
['/api/organizations/org-123/models/validate', '/api/v1/organizations/org-123/models/validate'],
];

for (const globalBackend of ['true', 'false']) {
void test(`rewrites every legacy gateway route in production with GLOBAL_KILO_BACKEND=${globalBackend}`, () => {
const env = {
NODE_ENV: 'production',
VERCEL_ENV: 'production',
VERCEL_TARGET_ENV: 'production',
GLOBAL_KILO_BACKEND: globalBackend,
AI_GATEWAY_PORT: '9999',
KILO_PORT_OFFSET: '2500',
};
for (const [source, destination] of routes) {
const result = rewrite(`${source}?source=extension&stream=true`, env);
assert.equal(result?.protocol, 'https:', source);
assert.equal(result?.hostname, 'ai-gateway.kilo.ai', source);
assert.equal(result?.pathname, destination, source);
assert.deepEqual(result?.query, { source: 'extension', stream: 'true' }, source);
}
});

void test(`rewrites every legacy gateway route in staging with GLOBAL_KILO_BACKEND=${globalBackend}`, () => {
for (const vercelEnv of ['preview', 'production']) {
const env = {
NODE_ENV: 'production',
VERCEL_ENV: vercelEnv,
VERCEL_TARGET_ENV: 'staging',
GLOBAL_KILO_BACKEND: globalBackend,
};
for (const [source, destination] of routes) {
const result = rewrite(`${source}?source=extension&stream=true`, env);
assert.equal(result?.protocol, 'https:', source);
assert.equal(result?.hostname, 'staging-ai-gateway.kilo.ai', source);
assert.equal(result?.pathname, destination, source);
assert.deepEqual(result?.query, { source: 'extension', stream: 'true' }, source);
}
}
});
}

void test('rewrites to the local gateway with default, offset, and explicit ports', () => {
for (const [env, port] of [
[{}, '3010'],
[{ KILO_PORT_OFFSET: '2500' }, '5510'],
[{ KILO_PORT_OFFSET: '2500', AI_GATEWAY_PORT: '6000' }, '6000'],
] as const) {
for (const [source, destination] of routes) {
const result = rewrite(source, {
...env,
NODE_ENV: 'development',
VERCEL_ENV: 'production',
VERCEL_TARGET_ENV: 'staging',
});
assert.equal(result?.protocol, 'http:', source);
assert.equal(result?.hostname, 'localhost', source);
assert.equal(result?.port, port, source);
assert.equal(result?.pathname, destination, source);
}
}
});

void test('leaves preview, test, and non-Vercel production handlers on the web app', () => {
for (const env of [
{ NODE_ENV: 'production', VERCEL_ENV: 'preview' },
{ NODE_ENV: 'production', VERCEL_ENV: 'preview', VERCEL_TARGET_ENV: 'preview' },
{ NODE_ENV: 'production', VERCEL_ENV: 'development' },
{ NODE_ENV: 'production' },
{ NODE_ENV: 'test' },
]) {
assert.deepEqual(getAiGatewayRewrites(env), []);
}
});

void test('does not move unrelated organization, Exa, marketplace, or cron routes to the gateway', () => {
for (const source of [
'/api/organizations',
'/api/organizations/org-123',
'/api/organizations/org-123/defaults',
'/api/organizations/org-123/modes',
'/api/organizations/org-123/tokens',
'/api/exa/search',
'/api/marketplace/mcps',
'/api/cron/sync-model-stats',
]) {
assert.equal(rewrite(source, { NODE_ENV: 'production', VERCEL_ENV: 'production' }), undefined);
}
});

void test('preserves encoded model paths and trailing slashes', () => {
const result = rewrite('/api/gateway/v1/models/provider/model%2Fvariant/endpoints/', {
NODE_ENV: 'production',
VERCEL_ENV: 'production',
});
assert.equal(result?.pathname, '/api/v1/models/provider/model%2Fvariant/endpoints');
});
Loading
Loading