Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export type PrepareSessionInput = {
initialPayload?: SendMessagePayload;
mode: AgentMode;
model: string;
/** Optional cheap same-vendor model for title/aux calls (Code Reviewer). */
smallModel?: string;
variant?: string;
// GitHub-specific params
githubRepo?: string;
Expand Down
57 changes: 55 additions & 2 deletions apps/web/src/lib/code-reviews/core/model-selection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { resolveEffectiveModel } from './model-selection';
import type { CodeReviewAgentConfig } from '@kilocode/db/schema-types';
import {
catalogPricesFromStoredModels,
resolveCheapSameVendorSmallModel,
resolveEffectiveModel,
} from './model-selection';
import type { CodeReviewAgentConfig, StoredModel } from '@kilocode/db/schema-types';

const FALLBACK = 'anthropic/claude-sonnet-4.6';

Expand All @@ -15,6 +19,15 @@ function baseConfig(
};
}

function priced(id: string, prompt: string): StoredModel {
return {
id,
name: id,
type: 'language',
endpoints: [{ pricing: { prompt, completion: prompt } }],
};
}

describe('resolveEffectiveModel', () => {
it('uses the global model when there are no overrides', () => {
const result = resolveEffectiveModel(baseConfig(), 'acme/api', FALLBACK);
Expand Down Expand Up @@ -119,3 +132,43 @@ describe('resolveEffectiveModel', () => {
expect(result.source).toBe('global');
});
});

describe('resolveCheapSameVendorSmallModel', () => {
const catalog = catalogPricesFromStoredModels({
'anthropic/claude-sonnet-4.6': priced('anthropic/claude-sonnet-4.6', '0.000003'),
'anthropic/claude-haiku-4.5': priced('anthropic/claude-haiku-4.5', '0.0000008'),
'anthropic/claude-opus-4.6': priced('anthropic/claude-opus-4.6', '0.000015'),
'openai/gpt-5': priced('openai/gpt-5', '0.00001'),
'openai/gpt-5-nano': priced('openai/gpt-5-nano', '0.0000001'),
});

it('picks the cheapest strictly-cheaper same-vendor sibling', () => {
expect(resolveCheapSameVendorSmallModel('anthropic/claude-sonnet-4.6', catalog)).toBe(
'anthropic/claude-haiku-4.5'
);
});

it('does not cross vendors', () => {
expect(resolveCheapSameVendorSmallModel('openai/gpt-5', catalog)).toBe('openai/gpt-5-nano');
});

it('leaves managed models unset when no cheaper sibling exists', () => {
expect(resolveCheapSameVendorSmallModel('anthropic/claude-haiku-4.5', catalog)).toBeUndefined();
});

it('falls back to the primary for sole-model direct-BYOK vendors', () => {
expect(resolveCheapSameVendorSmallModel('neuralwatt/glm-5.2-short', catalog)).toBe(
'neuralwatt/glm-5.2-short'
);
});

it('picks a cheaper BYOK sibling when catalog prices exist', () => {
const byokCatalog = catalogPricesFromStoredModels({
'neuralwatt/glm-5.2-short': priced('neuralwatt/glm-5.2-short', '0.000002'),
'neuralwatt/glm-tiny': priced('neuralwatt/glm-tiny', '0.0000001'),
});
expect(resolveCheapSameVendorSmallModel('neuralwatt/glm-5.2-short', byokCatalog)).toBe(
'neuralwatt/glm-tiny'
);
});
});
101 changes: 100 additions & 1 deletion apps/web/src/lib/code-reviews/core/model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,113 @@
* or Bitbucket). See `RepositoryModelOverrideSchema`.
*/

import type { CodeReviewAgentConfig } from '@kilocode/db/schema-types';
import type { CodeReviewAgentConfig, StoredModel } from '@kilocode/db/schema-types';
import { DIRECT_BYOK_PROVIDERS_META } from '@/lib/ai-gateway/providers/direct-byok/direct-byok-meta';
import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache';

export type EffectiveModelSelection = {
modelSlug: string;
thinkingEffort: string | null;
source: 'repository_override' | 'global';
};

/** Catalog entry used to pick a cheap same-vendor small model. */
export type CatalogModelPrice = {
id: string;
/** USD per input token; null when the catalog has no usable prompt price. */
promptPrice: number | null;
};

const DIRECT_BYOK_VENDORS = new Set(Object.keys(DIRECT_BYOK_PROVIDERS_META));

export function modelVendorId(modelId: string): string | undefined {
const vendor = modelId.split('/')[0]?.trim();
return vendor || undefined;
}

export function isDirectByokVendor(vendor: string | undefined): boolean {
return vendor != null && DIRECT_BYOK_VENDORS.has(vendor);
}

/**
* Build catalog price rows from the OpenRouter `StoredModel` map.
* Uses the cheapest endpoint prompt price per model.
*/
export function catalogPricesFromStoredModels(
models: Record<string, StoredModel>
): CatalogModelPrice[] {
return Object.values(models)
.filter(model => (model.type ?? 'language') === 'language' && model.endpoints.length > 0)
.map(model => {
const prices = model.endpoints
.map(endpoint =>
endpoint.pricing?.prompt != null ? Number.parseFloat(endpoint.pricing.prompt) : Number.NaN
)
.filter(price => Number.isFinite(price) && price >= 0);
return {
id: model.id,
promptPrice: prices.length > 0 ? Math.min(...prices) : null,
};
});
}

/**
* Pick a cheap same-vendor model for Code Reviewer title/aux calls.
*
* - Prefer the lowest-priced same-vendor sibling that is strictly cheaper than the
* primary when the primary's price is known.
* - When the primary has no catalog price, pick the cheapest same-vendor sibling.
* - Direct-BYOK vendors with no cheaper sibling fall back to the primary (user's
* key) so aux calls do not fall through to kilo-auto/small → Gemma on Kilo credits.
* - Managed vendors with no cheaper sibling leave small_model unset.
*/
export function resolveCheapSameVendorSmallModel(
primaryModelId: string,
catalog: readonly CatalogModelPrice[]
): string | undefined {
const vendor = modelVendorId(primaryModelId);
if (!vendor) return undefined;

const primaryPrice = catalog.find(entry => entry.id === primaryModelId)?.promptPrice ?? undefined;

const siblings = catalog.filter(
entry =>
entry.id !== primaryModelId &&
modelVendorId(entry.id) === vendor &&
entry.promptPrice != null &&
Number.isFinite(entry.promptPrice)
);

const cheaper =
primaryPrice != null && Number.isFinite(primaryPrice)
? siblings.filter(entry => (entry.promptPrice as number) < primaryPrice)
: siblings;

if (cheaper.length === 0) {
return isDirectByokVendor(vendor) ? primaryModelId : undefined;
}

cheaper.sort((a, b) => {
const priceDelta = (a.promptPrice as number) - (b.promptPrice as number);
return priceDelta !== 0 ? priceDelta : a.id.localeCompare(b.id);
});
return cheaper[0]?.id;
}

/**
* Resolve the Code Reviewer aux/title small model for a primary review model.
* Soft-fails to BYOK-primary or unset when the gateway catalog cannot be loaded.
*/
export async function resolveReviewSmallModel(primaryModelId: string): Promise<string | undefined> {
const vendor = modelVendorId(primaryModelId);
try {
const models = await getOpenRouterModelsMetadataFromDatabase();
return resolveCheapSameVendorSmallModel(primaryModelId, catalogPricesFromStoredModels(models));
} catch {
return isDirectByokVendor(vendor) ? primaryModelId : undefined;
}
}

/**
* Resolve the effective model for a review's repository.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ const mockFindPreviousCompletedReview = jest.fn();
const mockUpdatePreviousReviewSummary = jest.fn();
const mockUpdateRepositoryReviewInstructionsMetadata = jest.fn();
const mockGenerateReviewPrompt = jest.fn();
const mockResolveReviewSmallModel = jest.fn();

import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types';
import type * as CodeReviewsDb from '@/lib/code-reviews/db/code-reviews';
import type * as ModelSelection from '@/lib/code-reviews/core/model-selection';

jest.mock('@/lib/integrations/platforms/github/adapter', () => ({
generateGitHubInstallationToken: (...args: unknown[]) =>
Expand Down Expand Up @@ -62,6 +64,16 @@ jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
}));

jest.mock('@/lib/code-reviews/core/model-selection', () => {
const actual = jest.requireActual<typeof ModelSelection>(
'@/lib/code-reviews/core/model-selection'
);
return {
...actual,
resolveReviewSmallModel: (...args: unknown[]) => mockResolveReviewSmallModel(...args),
};
});

import { db } from '@/lib/drizzle';
import { insertTestUser } from '@/tests/helpers/user.helper';
import {
Expand Down Expand Up @@ -249,6 +261,8 @@ describe('prepareReviewPayload', () => {
mockUpdatePreviousReviewSummary.mockReset();
mockUpdateRepositoryReviewInstructionsMetadata.mockReset();
mockGenerateReviewPrompt.mockReset();
mockResolveReviewSmallModel.mockReset();
mockResolveReviewSmallModel.mockResolvedValue(undefined);
});

afterAll(async () => {
Expand Down Expand Up @@ -979,4 +993,39 @@ describe('prepareReviewPayload', () => {
upstreamBranch: 'refs/pull/1235/head',
});
});

it('includes resolved smallModel on GitHub session input when present', async () => {
const [review] = await db
.insert(cloud_agent_code_reviews)
.values(defineReview(testUser.id, integration.id))
.returning();
mockResolveReviewSmallModel.mockResolvedValueOnce('anthropic/claude-haiku-4.5');

const payload = await prepareReviewPayload({
reviewId: review.id,
owner: { type: 'user', id: testUser.id, userId: testUser.id },
agentConfig: { config: baseAgentConfig },
platform: 'github',
});

expect(mockResolveReviewSmallModel).toHaveBeenCalledWith('test-model');
expect(payload.sessionInput.smallModel).toBe('anthropic/claude-haiku-4.5');
});

it('omits smallModel from session input when no cheap sibling is resolved', async () => {
const [review] = await db
.insert(cloud_agent_code_reviews)
.values(defineReview(testUser.id, integration.id))
.returning();

const payload = await prepareReviewPayload({
reviewId: review.id,
owner: { type: 'user', id: testUser.id, userId: testUser.id },
agentConfig: { config: baseAgentConfig },
platform: 'github',
});

expect(mockResolveReviewSmallModel).toHaveBeenCalledWith('test-model');
expect(payload.sessionInput).not.toHaveProperty('smallModel');
});
});
12 changes: 12 additions & 0 deletions apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
type ReviewScope,
} from '../db/code-reviews';
import { DEFAULT_CODE_REVIEW_MODEL, DEFAULT_CODE_REVIEW_MODE } from '../core/constants';
import { resolveReviewSmallModel } from '../core/model-selection';
import type { Owner } from '../core';
import { generateReviewPrompt } from '../prompts/generate-prompt';
import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types';
Expand Down Expand Up @@ -113,6 +114,11 @@ export type SessionInput = {
prompt: string;
mode: 'code';
model: string;
/**
* Optional cheap same-vendor model for kilo title/aux calls. When omitted, the
* CLI falls through to its default small-model list (kilo-auto/small → Gemma).
*/
smallModel?: string;
/** Thinking effort variant name (e.g. "high", "max") — undefined means model default */
variant?: string;
upstreamBranch: string;
Expand Down Expand Up @@ -302,12 +308,14 @@ export async function prepareReviewPayload(
// Single source for the standard reviewer's model so the session input and the
// forward-shaped `reviewAgents[0]` can never drift apart.
const standardModel = config.model_slug || DEFAULT_CODE_REVIEW_MODEL;
const smallModel = await resolveReviewSmallModel(standardModel);
const sessionInput: SessionInput = {
gitUrl: `https://bitbucket.org/${workspaceSlug.data}/${repositorySlug.data}.git`,
kilocodeOrganizationId: owner.id,
prompt,
mode: DEFAULT_CODE_REVIEW_MODE as 'code',
model: standardModel,
...(smallModel ? { smallModel } : {}),
variant: config.thinking_effort ?? undefined,
upstreamBranch: review.head_ref,
platform: PLATFORM.BITBUCKET,
Expand Down Expand Up @@ -758,6 +766,7 @@ export async function prepareReviewPayload(
// Single source for the standard reviewer's model so the session input and the
// forward-shaped `reviewAgents[0]` can never drift apart.
const standardModel = config.model_slug || DEFAULT_CODE_REVIEW_MODEL;
const smallModel = await resolveReviewSmallModel(standardModel);
const gateThreshold = config.gate_threshold ?? 'off';
const githubCheckoutRef = getGitHubPullRequestCheckoutRef(review.pr_number);
const sessionInput: SessionInput =
Expand All @@ -771,6 +780,7 @@ export async function prepareReviewPayload(
prompt,
mode: DEFAULT_CODE_REVIEW_MODE as 'code',
model: standardModel,
...(smallModel ? { smallModel } : {}),
variant,
upstreamBranch: review.head_ref,
}
Expand All @@ -784,6 +794,7 @@ export async function prepareReviewPayload(
prompt,
mode: DEFAULT_CODE_REVIEW_MODE as 'code',
model: standardModel,
...(smallModel ? { smallModel } : {}),
variant,
upstreamBranch: review.head_ref,
...(gateThreshold !== 'off' ? { gateThreshold } : {}),
Expand All @@ -797,6 +808,7 @@ export async function prepareReviewPayload(
prompt,
mode: DEFAULT_CODE_REVIEW_MODE as 'code',
model: standardModel,
...(smallModel ? { smallModel } : {}),
variant,
upstreamBranch: githubCheckoutRef,
...(gateThreshold !== 'off' ? { gateThreshold } : {}),
Expand Down
2 changes: 2 additions & 0 deletions packages/worker-utils/src/cloud-agent-next-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type CloudAgentPrepareSessionInput = {
prompt: string;
mode: string;
model: string;
/** Optional cheap same-vendor model for title/aux calls (Code Reviewer). */
smallModel?: string;
variant?: string;
githubRepo?: string;
githubToken?: string;
Expand Down
2 changes: 2 additions & 0 deletions services/cloud-agent-next/src/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export function renderExecutionTurnContent(turn: AcceptedExecutionTurn): string
export type ModelChoice = {
model: string;
variant?: string;
/** Optional cheap same-vendor model for title/aux calls (Code Reviewer). */
smallModel?: string;
};

/** Fully resolved agent selection. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ const MetadataAgentSchema = z
.object({
mode: z.string().optional(),
model: z.string().optional(),
smallModel: z.string().optional(),
variant: z
.string()
.max(50)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export function prepareInputToSessionCreateRequest(input: PrepareInput): Session
agent: {
mode: input.mode,
model: input.model,
...(input.smallModel ? { smallModel: input.smallModel } : {}),
variant: input.variant,
},
repository,
Expand Down
5 changes: 5 additions & 0 deletions services/cloud-agent-next/src/router/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ export const PrepareSessionInput = z
'Kilo Code execution mode (built-in or custom slug from runtimeAgents)'
),
model: modelIdSchema.describe('AI model to use'),
smallModel: modelIdSchema
.optional()
.describe(
'Optional cheap same-vendor model for title/aux calls (Code Reviewer). When omitted, CLI defaults apply.'
),
variant: z
.string()
.max(50)
Expand Down
Loading