Skip to content
Merged
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
15 changes: 6 additions & 9 deletions apps/web/src/app/admin/gateway/CustomPricingContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useTRPC } from '@/lib/trpc/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CustomPricingConfigSchema, type CustomPricingConfig } from '@kilocode/db/schema-types';
import { EMPTY_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { deepStrict } from '@/lib/zod/deep-strict';
import { formatZodError } from '@/lib/zod/format-zod-error';

Expand Down Expand Up @@ -42,7 +43,7 @@ export function CustomPricingContent() {

useEffect(() => {
if (data) {
setJson(toJson(data.config ?? data.fallback));
setJson(toJson(data.config ?? EMPTY_CUSTOM_PRICING_CONFIG));
setValidationError(null);
setHasChanges(false);
}
Expand All @@ -54,11 +55,7 @@ export function CustomPricingContent() {
void queryClient.invalidateQueries({
queryKey: trpc.admin.customPricing.get.queryKey(),
});
toast.success(
result.config
? 'Custom pricing saved'
: 'Custom pricing cleared; the in-code defaults apply again'
);
toast.success(result.config ? 'Custom pricing saved' : 'Custom pricing cleared');
},
onError: error => {
toast.error(formatZodError(error));
Expand Down Expand Up @@ -108,15 +105,15 @@ export function CustomPricingContent() {
<code>input_cache_write_per_million</code> to <code>null</code> to bill cache tokens at
the prompt price. The optional <code>discountPercentage</code> is only shown in the
model name. With <code>fallbackOnly</code>, the prices apply only when the upstream
reports no cost. When no configuration is stored, the in-code defaults shown below
apply. Changes take effect within one minute on warm instances.
reports no cost. When no configuration is stored, no custom pricing applies. Changes
take effect within one minute on warm instances.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
{hasStoredConfig
? 'Showing the stored configuration.'
: 'No configuration stored. The gateway uses the in-code defaults shown below.'}
: 'No configuration stored. The gateway applies no custom pricing.'}
</p>
<div className="border-input overflow-hidden rounded-md border">
<Editor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ import {
} from '@kilocode/web-shared/lib/ai-gateway/providers/google';
import { formatPrice } from '@/components/models/util';
import { getOfferingPricing } from '@/components/organizations/providers-and-models/offeringPricing';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { TEST_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/tests/helpers/custom-pricing.helper';

describe('getOfferingPricing', () => {
test('shows list prices for models without custom pricing', () => {
expect(
getOfferingPricing(CUSTOM_PRICING_FALLBACK_CONFIG, 'openai/gpt-x', {
getOfferingPricing(TEST_CUSTOM_PRICING_CONFIG, 'openai/gpt-x', {
prompt: '0.000001',
completion: '0.000005',
})
).toEqual({ promptPrice: '0.000001', completionPrice: '0.000005' });
});

test('undoes the OpenRouter discount for models without custom pricing', () => {
const pricing = getOfferingPricing(CUSTOM_PRICING_FALLBACK_CONFIG, 'openai/gpt-x', {
const pricing = getOfferingPricing(TEST_CUSTOM_PRICING_CONFIG, 'openai/gpt-x', {
prompt: '0.0000005',
completion: '0.0000025',
discount: 0.5,
Expand All @@ -30,22 +30,18 @@ describe('getOfferingPricing', () => {
});

test('shows the custom price with the list price as the original', () => {
const pricing = getOfferingPricing(
CUSTOM_PRICING_FALLBACK_CONFIG,
GEMINI_FLASH_CURRENT_MODEL_ID,
{
prompt: '0.0000015',
completion: '0.0000075',
}
);
const pricing = getOfferingPricing(TEST_CUSTOM_PRICING_CONFIG, GEMINI_FLASH_CURRENT_MODEL_ID, {
prompt: '0.0000015',
completion: '0.0000075',
});
expect(formatPrice(pricing.promptPrice)).toBe('$0.75/1M tokens');
expect(formatPrice(pricing.completionPrice)).toBe('$3.75/1M tokens');
expect(pricing.originalPromptPrice).toBe('0.0000015');
expect(pricing.originalCompletionPrice).toBe('0.0000075');
});

test('compares the custom price against the undiscounted list price', () => {
const pricing = getOfferingPricing(CUSTOM_PRICING_FALLBACK_CONFIG, GEMINI_4_ARGON_MODEL_ID, {
const pricing = getOfferingPricing(TEST_CUSTOM_PRICING_CONFIG, GEMINI_4_ARGON_MODEL_ID, {
prompt: '0.000002',
completion: '0.00001',
discount: 0.5,
Expand All @@ -56,14 +52,10 @@ describe('getOfferingPricing', () => {
});

test('omits the original price when the custom price is not lower', () => {
const pricing = getOfferingPricing(
CUSTOM_PRICING_FALLBACK_CONFIG,
GEMINI_FLASH_CURRENT_MODEL_ID,
{
prompt: '0.00000075',
completion: '0.000002',
}
);
const pricing = getOfferingPricing(TEST_CUSTOM_PRICING_CONFIG, GEMINI_FLASH_CURRENT_MODEL_ID, {
prompt: '0.00000075',
completion: '0.000002',
});
expect(formatPrice(pricing.promptPrice)).toBe('$0.75/1M tokens');
expect(pricing.originalPromptPrice).toBeUndefined();
expect(formatPrice(pricing.completionPrice)).toBe('$3.75/1M tokens');
Expand All @@ -72,7 +64,7 @@ describe('getOfferingPricing', () => {

test('does not apply custom pricing to other variants of the model', () => {
expect(
getOfferingPricing(CUSTOM_PRICING_FALLBACK_CONFIG, `${GEMINI_FLASH_CURRENT_MODEL_ID}:free`, {
getOfferingPricing(TEST_CUSTOM_PRICING_CONFIG, `${GEMINI_FLASH_CURRENT_MODEL_ID}:free`, {
prompt: '0',
completion: '0',
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import { useQuery } from '@tanstack/react-query';
import type { CustomPricingConfig } from '@kilocode/db/schema-types';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { EMPTY_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { useTRPC } from '@/lib/trpc/utils';

/** The custom pricing Kilo bills; the in-code fallback is used until the stored value loads. */
/** The custom pricing Kilo bills; empty until the stored value loads. */
export function useCustomPricingConfig(): CustomPricingConfig {
const trpc = useTRPC();
const { data } = useQuery({
...trpc.models.customPricing.queryOptions(),
staleTime: 5 * 60_000,
});
return data ?? CUSTOM_PRICING_FALLBACK_CONFIG;
return data ?? EMPTY_CUSTOM_PRICING_CONFIG;
}
4 changes: 2 additions & 2 deletions apps/web/src/lib/agent-briefing/summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getUnavailableModelIds } from '@kilocode/web-shared/lib/ai-gateway/unav
import { getPreferredProviderOrderConfig } from '@kilocode/web-shared/lib/ai-gateway/providers/preferred-provider-order-config';
import { enforceRequestCostLimit } from '@kilocode/web-shared/lib/ai-gateway/request-cost-limit';
import { getCustomPricingConfig } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing-config';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { TEST_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/tests/helpers/custom-pricing.helper';
import { briefingPriceBound, briefingPrompt } from './summary';
import type { BriefingReportRow } from './store';

Expand Down Expand Up @@ -53,7 +53,7 @@ describe('briefing model pricing', () => {
jest.mocked(getOpenRouterModelsMetadataFromDatabase).mockResolvedValue({});
jest.mocked(getUnavailableModelIds).mockResolvedValue(new Set<string>());
jest.mocked(getPreferredProviderOrderConfig).mockResolvedValue({ rules: [] });
jest.mocked(getCustomPricingConfig).mockResolvedValue(CUSTOM_PRICING_FALLBACK_CONFIG);
jest.mocked(getCustomPricingConfig).mockResolvedValue(TEST_CUSTOM_PRICING_CONFIG);
jest
.mocked(getRawOpenRouterModels)
.mockResolvedValue({ data: [aion] } satisfies OpenRouterModelsResponse);
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/routers/admin/custom-pricing-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it } from '@jest/globals';
import { cleanupDbForTest, db } from '@kilocode/web-shared/lib/drizzle';
import { ai_gateway_config, type User } from '@kilocode/db/schema';
import type { CustomPricingConfig } from '@kilocode/db/schema-types';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper';
import { createCallerForUser } from '@/routers/test-utils';

Expand Down Expand Up @@ -49,11 +48,10 @@ describe('adminCustomPricingRouter', () => {
});
});

it('returns the in-code fallback when unset', async () => {
it('returns null when unset', async () => {
const caller = await createCallerForUser(admin.id);
await expect(caller.admin.customPricing.get()).resolves.toEqual({
config: null,
fallback: CUSTOM_PRICING_FALLBACK_CONFIG,
});
});

Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/routers/admin/custom-pricing-router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { adminProcedure, createTRPCRouter } from '@kilocode/web-shared/lib/trpc/init';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { db } from '@kilocode/web-shared/lib/drizzle';
import { ai_gateway_config } from '@kilocode/db/schema';
import { CustomPricingConfigSchema } from '@kilocode/db/schema-types';
Expand All @@ -17,13 +16,10 @@ export const adminCustomPricingRouter = createTRPCRouter({
.from(ai_gateway_config)
.where(eq(ai_gateway_config.id, 1))
.limit(1);
if (!row?.config) return { config: null, fallback: CUSTOM_PRICING_FALLBACK_CONFIG };
if (!row?.config) return { config: null };
// jsonb sorts object keys; parsing restores the schema's key order for the editor.
const parsed = CustomPricingConfigSchema.safeParse(row.config);
return {
config: parsed.success ? parsed.data : row.config,
fallback: CUSTOM_PRICING_FALLBACK_CONFIG,
};
return { config: parsed.success ? parsed.data : row.config };
}),

set: adminProcedure.input(SetCustomPricingConfigSchema).mutation(async ({ input }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, test } from '@jest/globals';
import { cleanupDbForTest, db } from '@kilocode/web-shared/lib/drizzle';
import { ai_gateway_config } from '@kilocode/db/schema';
import { CustomPricingConfigSchema, type CustomPricingConfig } from '@kilocode/db/schema-types';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { EMPTY_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { TEST_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/tests/helpers/custom-pricing.helper';
import { readCustomPricingConfig } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing-config';

const tier = (start_context_length: number) => ({
Expand All @@ -24,13 +25,13 @@ describe('readCustomPricingConfig', () => {
await cleanupDbForTest();
});

test('returns the in-code fallback when no config row exists', async () => {
expect(await readCustomPricingConfig()).toBe(CUSTOM_PRICING_FALLBACK_CONFIG);
test('returns no custom pricing when no config row exists', async () => {
expect(await readCustomPricingConfig()).toBe(EMPTY_CUSTOM_PRICING_CONFIG);
});

test('returns the in-code fallback when the column is null', async () => {
test('returns no custom pricing when the column is null', async () => {
await db.insert(ai_gateway_config).values({ preferred_models: { models: ['provider/a'] } });
expect(await readCustomPricingConfig()).toBe(CUSTOM_PRICING_FALLBACK_CONFIG);
expect(await readCustomPricingConfig()).toBe(EMPTY_CUSTOM_PRICING_CONFIG);
});

test('reads the stored config', async () => {
Expand All @@ -52,9 +53,9 @@ describe('readCustomPricingConfig', () => {
});

describe('CustomPricingConfigSchema', () => {
test('accepts the in-code fallback', () => {
expect(CustomPricingConfigSchema.parse(CUSTOM_PRICING_FALLBACK_CONFIG)).toEqual(
CUSTOM_PRICING_FALLBACK_CONFIG
test('accepts the test fixture', () => {
expect(CustomPricingConfigSchema.parse(TEST_CUSTOM_PRICING_CONFIG)).toEqual(
TEST_CUSTOM_PRICING_CONFIG
);
});

Expand Down
12 changes: 6 additions & 6 deletions packages/web-shared/src/lib/ai-gateway/custom-pricing-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,25 @@ import { CustomPricingConfigSchema, type CustomPricingConfig } from '@kilocode/d
import { eq } from 'drizzle-orm';
import { createCachedFetch } from '@kilocode/web-shared/lib/cached-fetch';
import { readDb } from '@kilocode/web-shared/lib/drizzle';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';
import { EMPTY_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/custom-pricing.constants';

/**
* Returns the stored custom pricing, or the in-code fallback when none is
* stored. Throws on an invalid stored config, so the cached getter keeps its
* last valid value.
* Returns the stored custom pricing, or no custom pricing when none is stored.
* Throws on an invalid stored config, so the cached getter keeps its last valid
* value.
*/
export async function readCustomPricingConfig(): Promise<CustomPricingConfig> {
const [row] = await readDb
.select({ config: ai_gateway_config.custom_pricing })
.from(ai_gateway_config)
.where(eq(ai_gateway_config.id, 1))
.limit(1);
if (!row?.config) return CUSTOM_PRICING_FALLBACK_CONFIG;
if (!row?.config) return EMPTY_CUSTOM_PRICING_CONFIG;
return CustomPricingConfigSchema.parse(row.config);
}

export const getCustomPricingConfig = createCachedFetch(
readCustomPricingConfig,
60_000,
CUSTOM_PRICING_FALLBACK_CONFIG
EMPTY_CUSTOM_PRICING_CONFIG
);
Original file line number Diff line number Diff line change
@@ -1,49 +1,9 @@
import type { CustomPricing, CustomPricingConfig } from '@kilocode/db/schema-types';
import {
GEMINI_4_ARGON_MODEL_ID,
GEMINI_FLASH_CURRENT_MODEL_ID,
} from '@kilocode/web-shared/lib/ai-gateway/providers/google';

// Keep this file free of server-only dependencies: the organization Providers &
// Models screen imports it to show the prices Kilo actually bills.

/** Used when `ai_gateway_config.custom_pricing` is unset or cannot be read. */
export const CUSTOM_PRICING_FALLBACK_CONFIG: CustomPricingConfig = {
models: [
// Google's 50% promotional discount is available through the end of 2026.
{
model: GEMINI_FLASH_CURRENT_MODEL_ID,
discountPercentage: 50,
pricing: [
{
start_context_length: 0,
pricing: {
prompt_per_million: 0.75,
completion_per_million: 3.75,
input_cache_read_per_million: 0.075,
input_cache_write_per_million: 0.0416666666667,
},
},
],
},
// OpenRouter lists a 50% Google discount on every Gemini 4 Argon endpoint.
{
model: GEMINI_4_ARGON_MODEL_ID,
discountPercentage: 50,
pricing: [
{
start_context_length: 0,
pricing: {
prompt_per_million: 2,
completion_per_million: 10,
input_cache_read_per_million: 0.1,
input_cache_write_per_million: 0.1875,
},
},
],
},
],
};
export const EMPTY_CUSTOM_PRICING_CONFIG: CustomPricingConfig = { models: [] };

export function getCustomPricing(
config: CustomPricingConfig,
Expand Down
4 changes: 2 additions & 2 deletions packages/web-shared/src/lib/ai-gateway/custom-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
applyCustomPricingToModel,
calculateCustomCost_mUsd,
} from './custom-pricing';
import { CUSTOM_PRICING_FALLBACK_CONFIG } from './custom-pricing.constants';
import { TEST_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/tests/helpers/custom-pricing.helper';

jest.mock('@sentry/nextjs', () => ({ captureMessage: jest.fn() }));

Expand Down Expand Up @@ -47,7 +47,7 @@ const makeUsage = (overrides: Partial<Parameters<typeof calculateCustomCost_mUsd
...overrides,
});

const config = CUSTOM_PRICING_FALLBACK_CONFIG;
const config = TEST_CUSTOM_PRICING_CONFIG;

describe('custom model pricing', () => {
test('replaces upstream Gemini 3.8 Flash pricing with the promotional rates', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ai_gateway_config } from '@kilocode/db/schema';
import { NextRequest } from 'next/server';
import { getOpenRouterModelsMetadataFromDatabase } from '@kilocode/web-shared/lib/ai-gateway/providers/gateway-models-cache';
import { GEMINI_FLASH_CURRENT_MODEL_ID } from '@kilocode/web-shared/lib/ai-gateway/providers/google';
import { TEST_CUSTOM_PRICING_CONFIG } from '@kilocode/web-shared/tests/helpers/custom-pricing.helper';
import { handleModelEndpointsRequest } from './model-endpoints';

jest.mock('@kilocode/web-shared/lib/ai-gateway/providers/gateway-models-cache', () => ({
Expand All @@ -16,9 +17,10 @@ const mockedGetOpenRouterModelsMetadataFromDatabase = jest.mocked(

beforeAll(async () => {
await cleanupDbForTest();
await db
.insert(ai_gateway_config)
.values({ unavailable_models: { models: ['google/gemma-4-31b-it:free'] } });
await db.insert(ai_gateway_config).values({
unavailable_models: { models: ['google/gemma-4-31b-it:free'] },
custom_pricing: TEST_CUSTOM_PRICING_CONFIG,
});
});

function request(modelId: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ describe('client-safe model imports', () => {
);
expect(
customPricing.getCustomPricing(
customPricing.CUSTOM_PRICING_FALLBACK_CONFIG,
customPricing.EMPTY_CUSTOM_PRICING_CONFIG,
google.GEMINI_FLASH_CURRENT_MODEL_ID
)
).toBeDefined();
).toBeUndefined();
expect(modelUtils.normalizeModelId(google.GEMMA_4_26B_A4B_IT_FREE_ID)).toBe(
google.GEMMA_4_26B_A4B_IT_ID
);
Expand Down
Loading
Loading