Skip to content
Draft
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
4 changes: 3 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,9 @@ With `auto`, the primary worktree gets offset 0 (default ports), and secondary w

`pnpm dev:start` also passes a worktree-local Wrangler service-discovery registry at `.wrangler/dev-registry` into its tmux session. For worktrees with distinct `kilo-dev-*` session names, this allows concurrent offset Worker stacks such as `agents` to use the same local Worker names without resolving bindings to Workers running from sibling worktrees. The absolute registry path is recorded in `dev/logs/manifest.json` for diagnostics.

Infrastructure containers (`postgres` on 5432, `redis` on 6379, `grafana` on 4000) always bind to their fixed host ports regardless of the offset - they are shared services, not per-worktree instances. Concurrent worktrees reuse those containers, and `pnpm dev:stop` leaves them running while another `kilo-dev-*` session remains active.
Infrastructure containers (`postgres` on 5432, `redis` on 6379, `redis-http` on 8079, `grafana` on 4000) always bind to their fixed host ports regardless of the offset - they are shared services, not per-worktree instances. Concurrent worktrees reuse those containers, and `pnpm dev:stop` leaves them running while another `kilo-dev-*` session remains active.

The Next.js dev script exports local Redis defaults before `next dev` starts: `UPSTASH_REDIS_REST_URL=http://localhost:8079` and `UPSTASH_REDIS_REST_TOKEN=example_token` for the shared `@upstash/redis` REST helper, plus `REDIS_URL=redis://localhost:6379` for Chat SDK state because `@chat-adapter/state-redis` uses the Redis TCP protocol.

## Troubleshooting

Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"@types/js-cookie": "3.0.6",
"@types/js-yaml": "4.0.9",
"@types/mdx": "2.0.13",
"@upstash/redis": "^1.38.0",
"@vercel/functions": "3.4.6",
"@vercel/otel": "2.1.2",
"@workos-inc/node": "catalog:",
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { upstreamRequest } from '@/lib/ai-gateway/providers/upstream-request';
import { getOpenRouterModels } from '@/lib/ai-gateway/providers/gateway-models-cache';
import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server';
import { accountForMicrodollarUsage } from '@/lib/ai-gateway/llm-proxy-helpers';
import { redisGet, redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import type { Provider } from '@/lib/ai-gateway/providers/types';

jest.mock('next/server', () => {
Expand Down Expand Up @@ -39,7 +39,9 @@ jest.mock('@/lib/ai-gateway/abuse-service', () => {
jest.mock('@/lib/ai-gateway/providers/get-provider');
jest.mock('@/lib/ai-gateway/providers/upstream-request');
jest.mock('@/lib/ai-gateway/providers/gateway-models-cache');
jest.mock('@/lib/redis');
jest.mock('@/lib/redis', () => ({
redisClient: { get: jest.fn(), set: jest.fn() },
}));
jest.mock('@/lib/ai-gateway/o11y/api-metrics.server', () => ({
emitApiMetricsForResponse: jest.fn(),
getToolsAvailable: jest.fn(() => false),
Expand All @@ -65,8 +67,8 @@ const mockedUpstreamRequest = jest.mocked(upstreamRequest);
const mockedGetOpenRouterModels = jest.mocked(getOpenRouterModels);
const mockedEmitApiMetricsForResponse = jest.mocked(emitApiMetricsForResponse);
const mockedAccountForMicrodollarUsage = jest.mocked(accountForMicrodollarUsage);
const mockedRedisGet = jest.mocked(redisGet);
const mockedRedisSet = jest.mocked(redisSet);
const mockedRedisGet = jest.mocked(redisClient.get);
const mockedRedisSet = jest.mocked(redisClient.set);

const provider = {
id: 'openrouter',
Expand Down Expand Up @@ -160,7 +162,7 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () =>
});
mockedClassifyAbuse.mockResolvedValue(classifyResult(null));
mockedRedisGet.mockResolvedValue(null);
mockedRedisSet.mockResolvedValue(true);
mockedRedisSet.mockResolvedValue('OK');
mockedGetOpenRouterModels.mockResolvedValue(
new Set(['nvidia/nemotron-3-super-120b-a12b:free'])
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/openrouter/providers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { NextResponse } from 'next/server';
import { captureException } from '@sentry/nextjs';
import { OpenRouterProvidersResponseSchema } from '@/lib/organizations/organization-types';
import { createCachedFetch } from '@/lib/cached-fetch';
import { redisGet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { GATEWAY_METADATA_REDIS_KEYS } from '@/lib/redis-keys';

const getProviders = createCachedFetch(
async () => {
const raw = await redisGet(GATEWAY_METADATA_REDIS_KEYS.openrouterProviders);
const raw = await redisClient.get<string>(GATEWAY_METADATA_REDIS_KEYS.openrouterProviders);
if (raw === null) return null;
return OpenRouterProvidersResponseSchema.shape.data.parse(JSON.parse(raw));
},
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lib/ai-gateway/abuse-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from '@/lib/ai-gateway/providers/openrouter/request-helpers';
import { ProxyErrorType } from '@/lib/proxy-error-types';
import { getAutoFreeCandidates } from '@/lib/ai-gateway/auto-model/resolution';
import { redisGet, redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { abuseRulesClassificationRedisKey } from '@/lib/redis-keys';
import type { FraudDetectionHeaders } from '@/lib/utils';
import { z } from 'zod';
Expand Down Expand Up @@ -315,7 +315,7 @@ export async function getCachedRulesEngineAction(
identityKey: string
): Promise<CachedRulesEngineAction | null> {
try {
const raw = await redisGet(abuseRulesClassificationRedisKey(identityKey));
const raw = await redisClient.get<string>(abuseRulesClassificationRedisKey(identityKey));
if (!raw) return null;
const action = parseCachedRulesEngineAction(raw);
return action !== undefined ? { identityKey, action } : null;
Expand All @@ -331,7 +331,7 @@ export async function cacheRulesEngineAction(args: {
}): Promise<void> {
if (!args.rulesEngine) return;
try {
await redisSet(
await redisClient.set(
abuseRulesClassificationRedisKey(args.identityKey),
args.rulesEngine.resolved_action ?? 'none'
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/ai-gateway/experiments/membership.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { redisGet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { EXPERIMENTED_PUBLIC_IDS_REDIS_KEY } from '@/lib/redis-keys';
import { createCachedFetch } from '@/lib/cached-fetch';

Expand All @@ -17,7 +17,7 @@ const EXPERIMENTED_PUBLIC_IDS_LOCAL_CACHE_TTL_MS = process.env.NODE_ENV === 'tes
const getExperimentedPublicIds = createCachedFetch<string[]>(
async () => {
try {
const cached = await redisGet(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
const cached = await redisClient.get<string>(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
if (cached === null) return [];
return parseStringArray(cached) ?? [];
} catch {
Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/lib/ai-gateway/experiments/pick-variant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { model_experiment, model_experiment_variant_version } from '@kilocode/db
import { eq } from 'drizzle-orm';
import { isPublicIdExperimented } from './membership';
import { pickModelExperimentVariant } from './pick-variant';
import { redisDel, redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { EXPERIMENTED_PUBLIC_IDS_REDIS_KEY } from '@/lib/redis-keys';
import type { User } from '@kilocode/db/schema';

Expand All @@ -20,7 +20,11 @@ const upstreamB = {
internal_id: 'partner-checkpoint-b',
base_url: 'https://partner.example.com/v1',
};
const redisIt = process.env.REDIS_URL ? it : it.skip;
const redisIt =
(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) ||
(process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN)
? it
: it.skip;

beforeEach(async () => {
await cleanupDbForTest();
Expand All @@ -33,15 +37,15 @@ beforeEach(async () => {
async function clearRoutingCaches() {
// Tests share the dev Redis instance across runs; flush the membership key
// so each test sees a fresh load path.
await redisDel(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
await redisClient.del(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
}

async function seedExperimentedPublicIds(ids: string[]): Promise<boolean> {
return await redisSet(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY, JSON.stringify(ids));
async function seedExperimentedPublicIds(ids: string[]): Promise<string | null> {
return await redisClient.set(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY, JSON.stringify(ids));
}

afterEach(async () => {
await redisDel(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
await redisClient.del(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
});

async function makeActiveExperiment(opts: {
Expand Down Expand Up @@ -87,7 +91,7 @@ describe('isPublicIdExperimented', () => {

redisIt('returns true when the public id has an active experiment', async () => {
await makeActiveExperiment({ publicId: 'partner/preview-iset-active' });
expect(await seedExperimentedPublicIds(['partner/preview-iset-active'])).toBe(true);
expect(await seedExperimentedPublicIds(['partner/preview-iset-active'])).toBe('OK');
expect(await isPublicIdExperimented('partner/preview-iset-active')).toBe(true);
});

Expand All @@ -97,7 +101,7 @@ describe('isPublicIdExperimented', () => {
});
const caller = await createCallerForUser(admin.id);
await caller.admin.modelExperiments.pause({ id: experimentId });
expect(await seedExperimentedPublicIds(['partner/preview-iset-paused'])).toBe(true);
expect(await seedExperimentedPublicIds(['partner/preview-iset-paused'])).toBe('OK');
expect(await isPublicIdExperimented('partner/preview-iset-paused')).toBe(true);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
} from '@/lib/ai-gateway/providers/direct-byok/types';
import type { DirectUserByokInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { createCachedFetch } from '@/lib/cached-fetch';
import { redisGet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { directByokModelsRedisKey } from '@/lib/redis-keys';
import type { OpenCodeVariant } from '@kilocode/db/schema-types';

Expand All @@ -24,7 +24,7 @@ export function cachedEnhancedDirectByokModelList({
enhanceDirectByokModelList({
recommendedModels,
remainingModels: DirectByokModelArraySchema.parse(
JSON.parse((await redisGet(directByokModelsRedisKey(providerId))) ?? '[]')
JSON.parse((await redisClient.get<string>(directByokModelsRedisKey(providerId))) ?? '[]')
),
variants,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as z from 'zod';
import { type DirectByokModel } from '@/lib/ai-gateway/providers/direct-byok/types';
import type { DirectUserByokInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { directByokModelsRedisKey } from '@/lib/redis-keys';

const DEFAULT_MAX_COMPLETION_TOKENS = 32_000;
Expand Down Expand Up @@ -165,7 +165,7 @@ async function syncProvider(fetcher: ProviderFetcher, ctx: SyncContext): Promise
});
}

await redisSet(directByokModelsRedisKey(fetcher.providerId), JSON.stringify(models));
await redisClient.set(directByokModelsRedisKey(fetcher.providerId), JSON.stringify(models));
return models.length;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/ai-gateway/providers/gateway-models-cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { StoredModelSchema, type StoredModel } from '@kilocode/db';
import * as z from 'zod';
import { redisGet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { createCachedFetch } from '@/lib/cached-fetch';
import { GATEWAY_METADATA_REDIS_KEYS } from '@/lib/redis-keys';
import type { RedisKey } from '@/lib/redis-keys';
Expand All @@ -12,7 +12,7 @@ const StoredModelMapSchema = z.record(z.string(), StoredModelSchema);
function createStoredModelsFetcher(redisKey: RedisKey, name: string) {
return createCachedFetch<StoredModelMap>(
async () => {
const raw = JSON.parse((await redisGet(redisKey)) ?? 'null');
const raw = JSON.parse((await redisClient.get<string>(redisKey)) ?? 'null');
if (!raw || typeof raw !== 'object' || Object.keys(raw).length === 0) {
console.debug(`[getGatewayModels] no ${name} models found in Redis`);
return {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { logAutoModelChangesForAllOrgs } from '@/lib/organizations/auto-model-ch
import type { Provider } from '@/lib/ai-gateway/providers/types';
import type { StoredModel } from '@kilocode/db/schema-types';
import { EndpointsSchema, ModelsSchema } from '@kilocode/db/schema-types';
import { redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { GATEWAY_METADATA_REDIS_KEYS, type RedisKey } from '@/lib/redis-keys';
import { syncDirectByokModels } from '@/lib/ai-gateway/providers/direct-byok/sync-direct-byok';
import { ATTRIBUTION_HEADERS } from '@/lib/ai-gateway/providers/openrouter/attribution-headers';
Expand Down Expand Up @@ -312,7 +312,7 @@ async function mirrorToRedis(values: {
if (values.openrouterProviders) {
entries.push([GATEWAY_METADATA_REDIS_KEYS.openrouterProviders, values.openrouterProviders]);
}
await Promise.all(entries.map(([key, value]) => redisSet(key, JSON.stringify(value))));
await Promise.all(entries.map(([key, value]) => redisClient.set(key, JSON.stringify(value))));
}

/**
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/ai-gateway/providers/vercel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
} from '@/lib/ai-gateway/providers/openrouter/types';
import { isReasoningExplicitlyDisabled } from '@/lib/ai-gateway/providers/openrouter/request-helpers';
import { mapModelIdToVercel } from '@/lib/ai-gateway/providers/vercel/mapModelIdToVercel';
import { redisGet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { createCachedFetch } from '@/lib/cached-fetch';
import {
GatewayPercentageSchema,
Expand All @@ -27,7 +27,7 @@ import type { AnthropicProviderOptions } from '@ai-sdk/anthropic';

const getVercelRoutingPercentage = createCachedFetch(
async () => {
const raw = await redisGet(VERCEL_ROUTING_REDIS_KEY);
const raw = await redisClient.get<string>(VERCEL_ROUTING_REDIS_KEY);
if (!raw) return DEFAULT_VERCEL_PERCENTAGE;
const { vercel_routing_percentage } = GatewayPercentageSchema.parse(JSON.parse(raw));
return vercel_routing_percentage ?? DEFAULT_VERCEL_PERCENTAGE;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/blacklist-domains-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'server-only';
import * as z from 'zod';
import { redisGet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { getEnvVariable } from '@/lib/dotenvx';
import { createCachedFetch } from '@/lib/cached-fetch';
import { BLACKLIST_DOMAINS_REDIS_KEY } from '@/lib/redis-keys';
Expand Down Expand Up @@ -42,7 +42,7 @@ function getEnvFallbackDomains(): string[] {
*/
export const getBlacklistedDomains = createCachedFetch(
async (): Promise<string[]> => {
const raw = await redisGet(BLACKLIST_DOMAINS_REDIS_KEY);
const raw = await redisClient.get<string>(BLACKLIST_DOMAINS_REDIS_KEY);
if (raw) {
const parsed = BlacklistDomainsConfigSchema.parse(JSON.parse(raw));
if (parsed.domains.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
import { createHash } from 'node:crypto';
import { redisGetDel, redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import {
consumeGitHubUserAuthorizationState,
createGitHubUserAuthorizationState,
} from './user-authorization-state';

jest.mock('@/lib/redis', () => ({
redisGetDel: jest.fn(),
redisSet: jest.fn(),
redisClient: { set: jest.fn(), getdel: jest.fn() },
}));

const mockedRedisGetDel = jest.mocked(redisGetDel);
const mockedRedisSet = jest.mocked(redisSet);
const mockedRedisGetDel = jest.mocked(redisClient.getdel);
const mockedRedisSet = jest.mocked(redisClient.set);

describe('GitHub user authorization state', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedRedisSet.mockResolvedValue(true);
mockedRedisSet.mockResolvedValue('OK');
});

test('round-trips a single-use PKCE verifier bound to the Kilo user', async () => {
const created = await createGitHubUserAuthorizationState('user_123');
const codeVerifier = mockedRedisSet.mock.calls[0][1];
if (typeof codeVerifier !== 'string') {
throw new Error('Expected Redis PKCE value to be a string');
}
mockedRedisGetDel.mockResolvedValueOnce(codeVerifier).mockResolvedValueOnce(null);

expect(created.codeChallenge).toBe(
Expand All @@ -45,7 +47,7 @@ describe('GitHub user authorization state', () => {
});

test('fails closed if transient PKCE storage is unavailable', async () => {
mockedRedisSet.mockResolvedValue(false);
mockedRedisSet.mockResolvedValue(null);

await expect(createGitHubUserAuthorizationState('user_123')).rejects.toThrow(
'configured transient state storage'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
OAUTH_STATE_TTL_SECONDS,
verifyOAuthState,
} from '@/lib/integrations/oauth-state';
import { redisGetDel, redisSet } from '@/lib/redis';
import { redisClient } from '@/lib/redis';
import { githubUserAuthorizationPkceRedisKey } from '@/lib/redis-keys';

const STATE_PREFIX = 'github-user-authorization:';
Expand All @@ -26,10 +26,10 @@ export async function createGitHubUserAuthorizationState(
): Promise<GitHubUserAuthorizationState> {
const codeVerifier = randomBytes(32).toString('base64url');
const verifierRef = randomBytes(16).toString('base64url');
const stored = await redisSet(
const stored = await redisClient.set(
githubUserAuthorizationPkceRedisKey(verifierRef),
codeVerifier,
PKCE_TTL_SECONDS
{ ex: PKCE_TTL_SECONDS }
);
if (!stored) {
throw new Error('GitHub user authorization requires configured transient state storage');
Expand Down Expand Up @@ -58,7 +58,7 @@ export async function consumeGitHubUserAuthorizationState(
);
if (!parsed.success) return null;

const codeVerifier = await redisGetDel(
const codeVerifier = await redisClient.getdel<string>(
githubUserAuthorizationPkceRedisKey(parsed.data.verifierRef)
);
return codeVerifier ? { codeVerifier } : null;
Expand Down
Loading
Loading