Skip to content

Commit d5f420d

Browse files
committed
feat(ai-gateway): rewrite web routes to the standalone gateway
1 parent 99d79cd commit d5f420d

7 files changed

Lines changed: 217 additions & 32 deletions

File tree

‎DEVELOPMENT.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ pnpm drizzle:verify-bootstrap
221221
pnpm dev:start
222222
```
223223

224-
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.
224+
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.
225225
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.
226226
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.
227227

@@ -248,7 +248,6 @@ should pass against the local PostgreSQL database.
248248
|---|---|
249249
| `KILO_PORT_OFFSET=auto pnpm dev:start` | Start all local services in a tmux dashboard with worktree-safe ports |
250250
| `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 |
251-
| `pnpm dev:start ai-gateway` | Also start the standalone AI gateway app (`apps/ai-gateway`) on port 3010 plus the port offset |
252251
| `pnpm dev:stop` | Stop the tmux session and all services |
253252
| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) |
254253
| `pnpm dev:env --missing-secrets-only` | Sync env files and create missing local Secrets Store entries without refreshing existing secrets |

‎apps/ai-gateway/AGENTS.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ Next.js app that serves the AI gateway API on its own. It deploys to the
1616
`packages/web-shared/AGENTS.md`.
1717
- Handlers that depend on the path accept both apps' paths, such as the LLM
1818
proxy's path validation.
19-
- `pnpm dev` runs with the web app's environment files. Start it with
20-
`pnpm dev:start ai-gateway`.
19+
- `pnpm dev` runs with the web app's environment files. `pnpm dev:start` starts
20+
it automatically as a dependency of the web app, on port 3010 plus the worktree
21+
port offset. The web app rewrites gateway requests to it in development and to
22+
`https://ai-gateway.kilo.ai` in production, on both global and non-global backends.
2123
- Deploys are manual for now: run the `Deploy AI Gateway` workflow
2224
(`deploy-ai-gateway.yml`) from `main`. It deploys the commit of the last
2325
completed scheduled release for the chosen environment, so the database is

‎apps/web/ai-gateway-rewrites.mjs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export function getAiGatewayRewrites(env = process.env) {
2+
const origin =
3+
env.NODE_ENV === 'development'
4+
? `http://localhost:${env.AI_GATEWAY_PORT || 3010 + Number(env.KILO_PORT_OFFSET || 0)}`
5+
: env.VERCEL_ENV === 'production'
6+
? 'https://ai-gateway.kilo.ai'
7+
: undefined;
8+
9+
if (!origin) return [];
10+
11+
return [
12+
{
13+
source: '/api/gateway/typesafe/v1/systemone',
14+
destination: `${origin}/api/v1/systemone`,
15+
},
16+
...['gateway', 'openrouter'].flatMap(prefix => [
17+
{
18+
source: `/api/${prefix}/v1/:path*`,
19+
destination: `${origin}/api/v1/:path*`,
20+
},
21+
{
22+
source: `/api/${prefix}/:path*`,
23+
destination: `${origin}/api/v1/:path*`,
24+
},
25+
]),
26+
{
27+
source: '/api/fim/completions',
28+
destination: `${origin}/api/v1/fim/completions`,
29+
},
30+
{
31+
source: '/api/edit/completions',
32+
destination: `${origin}/api/v1/edit/completions`,
33+
},
34+
{
35+
source: '/api/organizations/:id/models',
36+
destination: `${origin}/api/v1/organizations/:id/models`,
37+
},
38+
{
39+
source: '/api/organizations/:id/models/validate',
40+
destination: `${origin}/api/v1/organizations/:id/models/validate`,
41+
},
42+
];
43+
}

‎apps/web/next.config.mjs‎

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import createMDX from '@next/mdx';
33
import { statSync } from 'fs';
44
import { resolve } from 'path';
55
import NextBundleAnalyzer from '@next/bundle-analyzer';
6+
import { getAiGatewayRewrites } from './ai-gateway-rewrites.mjs';
67

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

67+
experimental: {
68+
proxyTimeout: 800_000,
69+
},
70+
6671
async rewrites() {
67-
// Global API rewrites - proxy to global-api.kilo.ai when not on global backend
68-
// Uses beforeFiles to ensure the rewrite happens BEFORE filesystem routes are checked
69-
// See: https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites
7072
const globalApiRewrites =
7173
process.env.VERCEL_ENV === 'production' && process.env.GLOBAL_KILO_BACKEND !== 'true'
7274
? [
73-
{
74-
source: '/api/fim/completions',
75-
destination: 'https://global-api.kilo.ai/api/fim/completions',
76-
},
77-
{
78-
source: '/api/edit/completions',
79-
destination: 'https://global-api.kilo.ai/api/edit/completions',
80-
},
81-
{
82-
source: '/api/gateway/:path*',
83-
destination: 'https://global-api.kilo.ai/api/gateway/:path*',
84-
},
85-
{
86-
source: '/api/openrouter/:path*',
87-
destination: 'https://global-api.kilo.ai/api/openrouter/:path*',
88-
},
8975
{
9076
source: '/api/exa/:path*',
9177
destination: 'https://global-api.kilo.ai/api/exa/:path*',
@@ -98,7 +84,7 @@ const nextConfig = {
9884
: [];
9985

10086
return {
101-
beforeFiles: globalApiRewrites,
87+
beforeFiles: [...getAiGatewayRewrites(), ...globalApiRewrites],
10288
afterFiles: [
10389
// /config.json is handled by src/app/config.json/route.ts which merges
10490
// Kilo-specific schema additions on top of the upstream opencode schema.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import assert from 'node:assert/strict';
2+
import { createRequire } from 'node:module';
3+
import test from 'node:test';
4+
5+
import { getAiGatewayRewrites } from '../../apps/web/ai-gateway-rewrites.mjs';
6+
7+
const require = createRequire(new URL('../../apps/web/next.config.mjs', import.meta.url));
8+
const { getPathMatch } = require('next/dist/shared/lib/router/utils/path-match');
9+
const { prepareDestination } = require('next/dist/shared/lib/router/utils/prepare-destination');
10+
11+
function rewrite(url: string, env: NodeJS.ProcessEnv) {
12+
const request = new URL(url, 'http://localhost');
13+
for (const rule of getAiGatewayRewrites(env)) {
14+
const params = getPathMatch(rule.source)(request.pathname);
15+
if (params) {
16+
return prepareDestination({
17+
destination: rule.destination,
18+
params,
19+
query: Object.fromEntries(request.searchParams),
20+
appendParamsToQuery: true,
21+
}).parsedDestination;
22+
}
23+
}
24+
return undefined;
25+
}
26+
27+
const routes = [
28+
...['gateway', 'openrouter'].flatMap(prefix => [
29+
...['chat/completions', 'responses', 'messages', 'audio/transcriptions', 'transcription-models']
30+
.flatMap(endpoint => [endpoint, `v1/${endpoint}`])
31+
.map(endpoint => [`/api/${prefix}/${endpoint}`, `/api/v1/${endpoint.replace(/^v1\//, '')}`]),
32+
...['models-by-provider', 'providers', 'embeddings'].map(endpoint => [
33+
`/api/${prefix}/${endpoint}`,
34+
`/api/v1/${endpoint}`,
35+
]),
36+
...['models', 'v1/models'].map(endpoint => [
37+
`/api/${prefix}/${endpoint}/anthropic/claude-sonnet-4/endpoints`,
38+
'/api/v1/models/anthropic/claude-sonnet-4/endpoints',
39+
]),
40+
[`/api/${prefix}/models`, '/api/v1/models'],
41+
]),
42+
['/api/gateway/v1/models', '/api/v1/models'],
43+
['/api/gateway/embedding-models', '/api/v1/embedding-models'],
44+
['/api/openrouter/models/validate', '/api/v1/models/validate'],
45+
['/api/gateway/typesafe/v1/systemone', '/api/v1/systemone'],
46+
['/api/fim/completions', '/api/v1/fim/completions'],
47+
['/api/edit/completions', '/api/v1/edit/completions'],
48+
['/api/organizations/org-123/models', '/api/v1/organizations/org-123/models'],
49+
['/api/organizations/org-123/models/validate', '/api/v1/organizations/org-123/models/validate'],
50+
];
51+
52+
for (const globalBackend of ['true', 'false']) {
53+
void test(`rewrites every legacy gateway route in production with GLOBAL_KILO_BACKEND=${globalBackend}`, () => {
54+
const env = {
55+
NODE_ENV: 'production',
56+
VERCEL_ENV: 'production',
57+
GLOBAL_KILO_BACKEND: globalBackend,
58+
AI_GATEWAY_PORT: '9999',
59+
KILO_PORT_OFFSET: '2500',
60+
};
61+
for (const [source, destination] of routes) {
62+
const result = rewrite(`${source}?source=extension&stream=true`, env);
63+
assert.equal(result?.protocol, 'https:', source);
64+
assert.equal(result?.hostname, 'ai-gateway.kilo.ai', source);
65+
assert.equal(result?.pathname, destination, source);
66+
assert.deepEqual(result?.query, { source: 'extension', stream: 'true' }, source);
67+
}
68+
});
69+
}
70+
71+
void test('rewrites to the local gateway with default, offset, and explicit ports', () => {
72+
for (const [env, port] of [
73+
[{}, '3010'],
74+
[{ KILO_PORT_OFFSET: '2500' }, '5510'],
75+
[{ KILO_PORT_OFFSET: '2500', AI_GATEWAY_PORT: '6000' }, '6000'],
76+
] as const) {
77+
for (const [source, destination] of routes) {
78+
const result = rewrite(source, {
79+
...env,
80+
NODE_ENV: 'development',
81+
VERCEL_ENV: 'production',
82+
});
83+
assert.equal(result?.protocol, 'http:', source);
84+
assert.equal(result?.hostname, 'localhost', source);
85+
assert.equal(result?.port, port, source);
86+
assert.equal(result?.pathname, destination, source);
87+
}
88+
}
89+
});
90+
91+
void test('leaves preview, test, and non-Vercel production handlers on the web app', () => {
92+
for (const env of [
93+
{ NODE_ENV: 'production', VERCEL_ENV: 'preview' },
94+
{ NODE_ENV: 'production', VERCEL_ENV: 'development' },
95+
{ NODE_ENV: 'production' },
96+
{ NODE_ENV: 'test' },
97+
]) {
98+
assert.deepEqual(getAiGatewayRewrites(env), []);
99+
}
100+
});
101+
102+
void test('does not move unrelated organization, Exa, marketplace, or cron routes to the gateway', () => {
103+
for (const source of [
104+
'/api/organizations',
105+
'/api/organizations/org-123',
106+
'/api/organizations/org-123/defaults',
107+
'/api/organizations/org-123/modes',
108+
'/api/organizations/org-123/tokens',
109+
'/api/exa/search',
110+
'/api/marketplace/mcps',
111+
'/api/cron/sync-model-stats',
112+
]) {
113+
assert.equal(rewrite(source, { NODE_ENV: 'production', VERCEL_ENV: 'production' }), undefined);
114+
}
115+
});
116+
117+
void test('preserves encoded model paths and trailing slashes', () => {
118+
const result = rewrite('/api/gateway/v1/models/provider/model%2Fvariant/endpoints/', {
119+
NODE_ENV: 'production',
120+
VERCEL_ENV: 'production',
121+
});
122+
assert.equal(result?.pathname, '/api/v1/models/provider/model%2Fvariant/endpoints');
123+
});

‎dev/local/services.test.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ test('keeps auto routing workers in their own opt-in group', () => {
199199
assert.ok(!alwaysOn.includes('auto-routing-benchmark'));
200200
});
201201

202-
test('keeps the AI gateway app in its own opt-in group', () => {
202+
test('starts the AI gateway app whenever the web app starts', () => {
203203
const service = getService('ai-gateway');
204204

205205
assert.equal(service.group, 'ai-gateway');
@@ -219,7 +219,27 @@ test('keeps the AI gateway app in its own opt-in group', () => {
219219
'redis-http',
220220
'ai-gateway',
221221
]);
222-
assert.ok(!resolveGroups(getAlwaysOnGroupIds()).includes('ai-gateway'));
222+
assert.ok(resolveGroups(getAlwaysOnGroupIds()).includes('ai-gateway'));
223+
for (const target of ['nextjs', 'app', 'core', 'agents']) {
224+
const targets = resolveTargets([target]);
225+
assert.ok(targets.includes('ai-gateway'));
226+
assert.ok(targets.indexOf('ai-gateway') < targets.indexOf('nextjs'));
227+
}
228+
});
229+
230+
test('passes the same gateway port to the web app and gateway on every worktree offset', () => {
231+
const initialOffset = portOffset;
232+
try {
233+
for (const offset of [0, 2500]) {
234+
applyPortOffset(offset);
235+
const gateway = getService('ai-gateway');
236+
const web = getService('nextjs');
237+
assert.equal(web.command[1], `AI_GATEWAY_PORT=${gateway.port}`);
238+
assert.equal(gateway.command[1], web.command[1]);
239+
}
240+
} finally {
241+
applyPortOffset(initialOffset);
242+
}
223243
});
224244

225245
test('registers user data export with worktree-aware ports and dependencies', () => {
@@ -231,11 +251,12 @@ test('registers user data export with worktree-aware ports and dependencies', ()
231251
assert.equal(service.port, 8818 + portOffset);
232252
assert.deepEqual(service.dependsOn, ['postgres', 'nextjs']);
233253
assert.deepEqual(resolveTargets(['data-export']), [
254+
'redis',
234255
'postgres',
235256
'stripe',
236-
'redis',
237-
'cloudflare-session-ingest',
238257
'redis-http',
258+
'cloudflare-session-ingest',
259+
'ai-gateway',
239260
'nextjs',
240261
'user-data-export',
241262
]);

‎dev/local/services.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,14 @@ const serviceMeta: Record<string, ServiceMeta> = {
103103
// gets a 412 PRECONDITION_FAILED. Same precedent as mobile -> latency-ingest.
104104
nextjs: {
105105
group: 'core',
106-
dependsOn: ['postgres', 'redis', 'redis-http', 'stripe', 'cloudflare-session-ingest'],
106+
dependsOn: [
107+
'postgres',
108+
'redis',
109+
'redis-http',
110+
'stripe',
111+
'cloudflare-session-ingest',
112+
'ai-gateway',
113+
],
107114
},
108115
postgres: { group: 'core', dependsOn: [] },
109116
redis: { group: 'core', dependsOn: [] },
@@ -114,8 +121,6 @@ const serviceMeta: Record<string, ServiceMeta> = {
114121
dependsOn: ['postgres', 'nextjs'],
115122
dir: 'services/user-data-export',
116123
},
117-
// The standalone AI gateway app (apps/ai-gateway). It serves the gateway
118-
// routes the web app also serves, from the same database and Redis.
119124
'ai-gateway': {
120125
group: 'ai-gateway',
121126
dependsOn: ['postgres', 'redis', 'redis-http'],
@@ -699,7 +704,13 @@ function buildServiceDefs(): ServiceDef[] {
699704
dir: 'apps/web',
700705
port: nextjsTargetPort,
701706
dependsOn: meta.dependsOn,
702-
command: ['pnpm', 'run', 'dev'],
707+
command: [
708+
'env',
709+
`AI_GATEWAY_PORT=${AI_GATEWAY_BASE_PORT + portOffset}`,
710+
'pnpm',
711+
'run',
712+
'dev',
713+
],
703714
group: meta.group,
704715
});
705716
continue;

0 commit comments

Comments
 (0)