diff --git a/src/app/api/openrouter/[...path]/route.ts b/src/app/api/openrouter/[...path]/route.ts index adf45e227..ff2bc4861 100644 --- a/src/app/api/openrouter/[...path]/route.ts +++ b/src/app/api/openrouter/[...path]/route.ts @@ -13,7 +13,8 @@ import { isFreeModel, isDataCollectionRequiredOnKiloCodeOnly, isDeadFreeModel, - isSlackbotOnlyModel, + kiloFreeModels, + isReviewPromotionActive, isRateLimitedModel, } from '@/lib/models'; import { @@ -245,10 +246,18 @@ export async function POST(request: NextRequest): Promise m.public_id === originalModelIdLowerCased + )?.allowed_uses; + + if (modelAllowedUses?.includes('slackbot') && !internalApiUse) { return modelDoesNotExistResponse(); } + if (modelAllowedUses?.includes('review')) { + if (!internalApiUse || !isReviewPromotionActive(originalModelIdLowerCased)) { + return modelDoesNotExistResponse(); + } + } // Extract properties for usage context const tokenEstimates = estimateChatTokens(requestBodyParsed); diff --git a/src/components/cloud-agent-next/hooks/useResumeConfigModal.ts b/src/components/cloud-agent-next/hooks/useResumeConfigModal.ts index a1d72f330..e7f8c3f56 100644 --- a/src/components/cloud-agent-next/hooks/useResumeConfigModal.ts +++ b/src/components/cloud-agent-next/hooks/useResumeConfigModal.ts @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { isSlackbotOnlyModel } from '@/lib/models'; +import { kiloFreeModels } from '@/lib/models'; import { type DbSessionDetails, type IndexedDbSessionData } from '../store/db-session-atoms'; import { extractRepoFromGitUrl } from '../utils/git-utils'; import type { ResumeConfig, StreamResumeConfig } from '../types'; @@ -65,11 +65,10 @@ export function needsResumeConfigModal(params: { if (!loadedDbSession) return false; - if ( - loadedDbSession.last_model && - isSlackbotOnlyModel(loadedDbSession.last_model) && - !currentIndexedDbSession?.resumeConfig - ) + const lastModelRestricted = kiloFreeModels.find(m => m.public_id === loadedDbSession.last_model) + ?.allowed_uses?.length; + + if (loadedDbSession.last_model && lastModelRestricted && !currentIndexedDbSession?.resumeConfig) return true; const isCliSession = !loadedDbSession.cloud_agent_session_id; diff --git a/src/components/cloud-agent/hooks/useResumeConfigModal.ts b/src/components/cloud-agent/hooks/useResumeConfigModal.ts index bb127ace2..abb1fa2e5 100644 --- a/src/components/cloud-agent/hooks/useResumeConfigModal.ts +++ b/src/components/cloud-agent/hooks/useResumeConfigModal.ts @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { isSlackbotOnlyModel } from '@/lib/models'; +import { kiloFreeModels } from '@/lib/models'; import { type DbSessionDetails, type IndexedDbSessionData } from '../store/db-session-atoms'; import { extractRepoFromGitUrl } from '../utils/git-utils'; import type { ResumeConfig, StreamResumeConfig } from '../types'; @@ -65,11 +65,10 @@ export function needsResumeConfigModal(params: { if (!loadedDbSession) return false; - if ( - loadedDbSession.last_model && - isSlackbotOnlyModel(loadedDbSession.last_model) && - !currentIndexedDbSession?.resumeConfig - ) + const lastModelRestricted = kiloFreeModels.find(m => m.public_id === loadedDbSession.last_model) + ?.allowed_uses?.length; + + if (loadedDbSession.last_model && lastModelRestricted && !currentIndexedDbSession?.resumeConfig) return true; const isCliSession = !loadedDbSession.cloud_agent_session_id; diff --git a/src/lib/code-reviews/core/constants.ts b/src/lib/code-reviews/core/constants.ts index 293672c94..18a9f07a6 100644 --- a/src/lib/code-reviews/core/constants.ts +++ b/src/lib/code-reviews/core/constants.ts @@ -4,15 +4,26 @@ * Constants used throughout the code review system. */ +import { getActiveReviewPromotionModel } from '@/lib/models'; + // ============================================================================ // Review Configuration // ============================================================================ /** - * Default model for code reviews - * Using Claude Sonnet 4.5 as specified in the plan + * Default model for code reviews. + * Falls back to Claude Sonnet 4.5 when no promotion is active. + */ +const BASE_CODE_REVIEW_MODEL = 'anthropic/claude-sonnet-4.5'; + +/** + * Returns the effective default model for code reviews. + * If a review-only promotional model is currently active, it takes precedence. */ -export const DEFAULT_CODE_REVIEW_MODEL = 'anthropic/claude-sonnet-4.5'; +export function getDefaultCodeReviewModel(): string { + const promoModel = getActiveReviewPromotionModel(); + return promoModel?.internal_id ?? BASE_CODE_REVIEW_MODEL; +} /** * Default mode for cloud agent sessions diff --git a/src/lib/code-reviews/triggers/prepare-review-payload.ts b/src/lib/code-reviews/triggers/prepare-review-payload.ts index fcddfeede..b49564334 100644 --- a/src/lib/code-reviews/triggers/prepare-review-payload.ts +++ b/src/lib/code-reviews/triggers/prepare-review-payload.ts @@ -37,7 +37,8 @@ import type { } from '../prompts/generate-prompt'; import { getIntegrationById } from '@/lib/integrations/db/platform-integrations'; import { getCodeReviewById } from '../db/code-reviews'; -import { DEFAULT_CODE_REVIEW_MODEL, DEFAULT_CODE_REVIEW_MODE } from '../core/constants'; +import { DEFAULT_CODE_REVIEW_MODE, getDefaultCodeReviewModel } from '../core/constants'; +import { getActiveReviewPromotionModel } from '@/lib/models'; import type { Owner } from '../core'; import { generateReviewPrompt } from '../prompts/generate-prompt'; import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types'; @@ -341,7 +342,7 @@ export async function prepareReviewPayload( kilocodeOrganizationId: owner.type === 'org' ? owner.id : undefined, prompt, mode: DEFAULT_CODE_REVIEW_MODE as 'code', - model: config.model_slug || DEFAULT_CODE_REVIEW_MODEL, + model: config.model_slug || getDefaultCodeReviewModel(), upstreamBranch: review.head_ref, } : { @@ -351,7 +352,7 @@ export async function prepareReviewPayload( kilocodeOrganizationId: owner.type === 'org' ? owner.id : undefined, prompt, mode: DEFAULT_CODE_REVIEW_MODE as 'code', - model: config.model_slug || DEFAULT_CODE_REVIEW_MODEL, + model: config.model_slug || getDefaultCodeReviewModel(), upstreamBranch: review.head_ref, }; @@ -365,6 +366,21 @@ export async function prepareReviewPayload( }); } + // 6b. Log if a promotional model is being used for tracking + const activePromoModel = getActiveReviewPromotionModel(); + const effectiveModel = sessionInput.model; + if (activePromoModel && effectiveModel === activePromoModel.internal_id) { + logExceptInTest('[prepareReviewPayload] Using promotional model for code review', { + reviewId, + promotionModel: activePromoModel.public_id, + effectiveModel, + promotionStart: activePromoModel.promotion_start, + promotionEnd: activePromoModel.promotion_end, + ownerType: owner.type, + ownerId: owner.id, + }); + } + // 7. Build complete payload const payload: CodeReviewPayload = { reviewId, diff --git a/src/lib/models.ts b/src/lib/models.ts index 6768789de..0461fb681 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -7,6 +7,7 @@ import { CLAUDE_OPUS_CURRENT_MODEL_ID, CLAUDE_SONNET_CURRENT_MODEL_ID, opus_46_free_slackbot_model, + sonnet_46_free_review_model, } from '@/lib/providers/anthropic'; import { corethink_free_model } from '@/lib/providers/corethink'; import { giga_potato_model } from '@/lib/providers/gigapotato'; @@ -52,7 +53,7 @@ export function isFreeModel(model: string): boolean { } export function isRateLimitedModel(model: string): boolean { - return kiloFreeModels.some(m => m.public_id === model && m.is_enabled && !m.slackbot_only); + return kiloFreeModels.some(m => m.public_id === model && m.is_enabled && !m.allowed_uses?.length); } export function isDataCollectionRequiredOnKiloCodeOnly(model: string): boolean { @@ -65,6 +66,7 @@ export const kiloFreeModels = [ minimax_m21_free_model, minimax_m25_free_model, opus_46_free_slackbot_model, + sonnet_46_free_review_model, grok_code_fast_1_optimized_free_model, zai_glm47_free_model, zai_glm5_free_model, @@ -88,11 +90,26 @@ export function isDeadFreeModel(model: string): boolean { return !!kiloFreeModels.find(m => m.public_id === model && !m.is_enabled); } -/** - * Check if a model is only available through Kilo for Slack (internalApiUse). - * These models are hidden from the public model list and return "model does not exist" - * when accessed outside of the Slack integration. - */ -export function isSlackbotOnlyModel(model: string): boolean { - return !!kiloFreeModels.find(m => m.public_id === model && m.slackbot_only); +/** Returns true if the model has allowed_uses including 'review' and is within its promotion window. */ +export function isReviewPromotionActive(model: string, now = new Date()): boolean { + const freeModel = kiloFreeModels.find( + m => m.public_id === model && m.allowed_uses?.includes('review') && m.is_enabled + ); + if (!freeModel) return false; + if (freeModel.promotion_start && now < new Date(freeModel.promotion_start)) return false; + if (freeModel.promotion_end && now >= new Date(freeModel.promotion_end)) return false; + return true; +} + +/** Returns the first review-use model whose promotion window is currently active, or null. */ +export function getActiveReviewPromotionModel(now = new Date()): KiloFreeModel | null { + return ( + kiloFreeModels.find( + m => + m.allowed_uses?.includes('review') && + m.is_enabled && + (!m.promotion_start || now >= new Date(m.promotion_start)) && + (!m.promotion_end || now < new Date(m.promotion_end)) + ) ?? null + ); } diff --git a/src/lib/providers/anthropic.ts b/src/lib/providers/anthropic.ts index 921ba5685..6195d40d9 100644 --- a/src/lib/providers/anthropic.ts +++ b/src/lib/providers/anthropic.ts @@ -18,8 +18,26 @@ export const opus_46_free_slackbot_model = { gateway: 'openrouter', internal_id: 'anthropic/claude-opus-4.6', inference_providers: [], - slackbot_only: true, -} as KiloFreeModel; + allowed_uses: ['slackbot'], +} satisfies KiloFreeModel; + +export const SONNET_46_REVIEW_PROMO_MODEL_ID = 'anthropic/claude-sonnet-4.6:review'; + +export const sonnet_46_free_review_model = { + public_id: SONNET_46_REVIEW_PROMO_MODEL_ID, + display_name: 'Anthropic: Claude Sonnet 4.6 (Free for Code Reviewer)', + description: 'Claude Sonnet 4.6 — free for one week in Code Reviewer (review mode)', + context_length: 1_000_000, + max_completion_tokens: 16384, + is_enabled: true, + flags: ['reasoning', 'prompt_cache', 'vision'], + gateway: 'openrouter', + internal_id: 'anthropic/claude-sonnet-4.6', + inference_providers: ['anthropic'], + allowed_uses: ['review'], + promotion_start: '2026-02-18T11:00:00Z', // 6 AM East Coast + promotion_end: '2026-02-25T11:00:00Z', +} satisfies KiloFreeModel; const ENABLE_ANTHROPIC_STRICT_TOOL_USE = false; diff --git a/src/lib/providers/kilo-free-model.ts b/src/lib/providers/kilo-free-model.ts index 29c81f5b0..c7f079e36 100644 --- a/src/lib/providers/kilo-free-model.ts +++ b/src/lib/providers/kilo-free-model.ts @@ -3,6 +3,8 @@ import type { ProviderId } from '@/lib/providers/provider-id'; export type KiloFreeModelFlag = 'reasoning' | 'prompt_cache' | 'vision'; +export type KiloFreeModelUse = 'slackbot' | 'review'; + export type KiloFreeModel = { public_id: string; display_name: string; @@ -14,8 +16,11 @@ export type KiloFreeModel = { gateway: ProviderId; internal_id: string; inference_providers: OpenRouterInferenceProviderId[]; - /** If true, this model is only available through Kilo for Slack (internalApiUse) and hidden from public model list */ - slackbot_only?: boolean; + allowed_uses?: KiloFreeModelUse[]; + /** Promotion start date (ISO 8601). If set, the model is only active after this date. */ + promotion_start?: string; + /** Promotion end date (ISO 8601). If set, the model is disabled after this date. */ + promotion_end?: string; }; export function convertFromKiloModel(model: KiloFreeModel) { diff --git a/src/lib/providers/openrouter/index.ts b/src/lib/providers/openrouter/index.ts index ec8e6471a..624536621 100644 --- a/src/lib/providers/openrouter/index.ts +++ b/src/lib/providers/openrouter/index.ts @@ -60,7 +60,11 @@ function enhancedModelList(models: OpenRouterModel[]) { !kiloFreeModels.some(m => m.public_id === model.id && m.is_enabled) && !isRateLimitedToDeath(model.id) ) - .concat(kiloFreeModels.filter(m => m.is_enabled).map(model => convertFromKiloModel(model))) + .concat( + kiloFreeModels + .filter(m => m.is_enabled && !m.allowed_uses?.length) + .map(model => convertFromKiloModel(model)) + ) .concat([autoModel]) .map((model: OpenRouterModel) => { const preferredIndex = diff --git a/src/routers/admin-code-reviews-router.ts b/src/routers/admin-code-reviews-router.ts index 212f69f47..2df64ffc5 100644 --- a/src/routers/admin-code-reviews-router.ts +++ b/src/routers/admin-code-reviews-router.ts @@ -1,8 +1,9 @@ import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { db } from '@/lib/drizzle'; -import { cloud_agent_code_reviews, kilocode_users, organizations } from '@/db/schema'; +import { cloud_agent_code_reviews, cliSessions, kilocode_users, organizations } from '@/db/schema'; import * as z from 'zod'; import { sql, and, gte, lt, eq, isNotNull, desc, ilike, or, type SQL } from 'drizzle-orm'; +import { sonnet_46_free_review_model } from '@/lib/providers/anthropic'; /** * SQL condition to exclude "Insufficient credits" errors from failure metrics. @@ -371,4 +372,79 @@ export const adminCodeReviewsRouter = createTRPCRouter({ return result; }), + + // Sonnet 4.6 free review promotion tracking + getReviewPromotionStats: adminProcedure.query(async () => { + const promoModel = sonnet_46_free_review_model; + const promoStart = promoModel.promotion_start ?? '2026-02-18T00:00:00Z'; + const promoEnd = promoModel.promotion_end ?? '2026-02-25T00:00:00Z'; + const promoModelId = promoModel.internal_id; + + // Query code reviews that used the promotional model during the promotion window. + // Left join with cli_sessions to check last_model (cli_session_id is nullable). + // Rows without a cli_session are naturally excluded by the eq() predicate on last_model. + const result = await db + .select({ + total_promo_reviews: sql`COUNT(*)`, + completed_promo_reviews: sql`COUNT(*) FILTER (WHERE ${cloud_agent_code_reviews.status} = 'completed')`, + failed_promo_reviews: sql`COUNT(*) FILTER (WHERE ${cloud_agent_code_reviews.status} = 'failed')`, + unique_users: sql`COUNT(DISTINCT COALESCE(${cloud_agent_code_reviews.owned_by_user_id}, ${cloud_agent_code_reviews.owned_by_organization_id}::text))`, + unique_orgs: sql`COUNT(DISTINCT ${cloud_agent_code_reviews.owned_by_organization_id})`, + }) + .from(cloud_agent_code_reviews) + .leftJoin(cliSessions, eq(cloud_agent_code_reviews.cli_session_id, cliSessions.session_id)) + .where( + and( + gte(cloud_agent_code_reviews.created_at, promoStart), + lt(cloud_agent_code_reviews.created_at, promoEnd), + eq(cliSessions.last_model, promoModelId) + ) + ); + + // Daily breakdown + const dailyBreakdown = await db + .select({ + day: sql`DATE_TRUNC('day', ${cloud_agent_code_reviews.created_at})::date::text`, + total: sql`COUNT(*)`, + completed: sql`COUNT(*) FILTER (WHERE ${cloud_agent_code_reviews.status} = 'completed')`, + unique_users: sql`COUNT(DISTINCT COALESCE(${cloud_agent_code_reviews.owned_by_user_id}, ${cloud_agent_code_reviews.owned_by_organization_id}::text))`, + }) + .from(cloud_agent_code_reviews) + .leftJoin(cliSessions, eq(cloud_agent_code_reviews.cli_session_id, cliSessions.session_id)) + .where( + and( + gte(cloud_agent_code_reviews.created_at, promoStart), + lt(cloud_agent_code_reviews.created_at, promoEnd), + eq(cliSessions.last_model, promoModelId) + ) + ) + .groupBy(sql`DATE_TRUNC('day', ${cloud_agent_code_reviews.created_at})`) + .orderBy(sql`DATE_TRUNC('day', ${cloud_agent_code_reviews.created_at})`); + + const stats = result[0] ?? { + total_promo_reviews: 0, + completed_promo_reviews: 0, + failed_promo_reviews: 0, + unique_users: 0, + unique_orgs: 0, + }; + return { + promotionModelId: promoModel.public_id, + promotionInternalModelId: promoModelId, + promotionStart: promoStart, + promotionEnd: promoEnd, + isActive: new Date() >= new Date(promoStart) && new Date() < new Date(promoEnd), + totalPromoReviews: Number(stats.total_promo_reviews) || 0, + completedPromoReviews: Number(stats.completed_promo_reviews) || 0, + failedPromoReviews: Number(stats.failed_promo_reviews) || 0, + uniqueUsers: Number(stats.unique_users) || 0, + uniqueOrgs: Number(stats.unique_orgs) || 0, + dailyBreakdown: dailyBreakdown.map(row => ({ + day: row.day, + total: Number(row.total) || 0, + completed: Number(row.completed) || 0, + uniqueUsers: Number(row.unique_users) || 0, + })), + }; + }), });