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
37 changes: 35 additions & 2 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,34 @@ describe('kilo-auto/efficient classifier billing', () => {
expect(ctx.posthog_distinct_id).toBeUndefined();
});

it('bills classifier cost for the balanced alias using its requested model id', async () => {
mockedFetchEfficientAutoDecision.mockResolvedValue({
decision: {
model: 'anthropic/claude-haiku-4',
taskType: 'implementation',
subtaskType: 'feature_development',
source: 'benchmark',
tableVersion: 'v1',
sticky: false,
},
costUsd: 0.002,
});

const { POST } = await import('./route');
const response = await POST(makeRequest(makeBody('kilo-auto/balanced')) as never);

expect(response.status).toBe(200);
await Promise.resolve();
await Promise.resolve();

expect(mockedFetchEfficientAutoDecision).toHaveBeenCalledWith(
expect.objectContaining({ requestedModel: 'kilo-auto/balanced' })
);
expect(mockedLogMicrodollarUsage).toHaveBeenCalledTimes(1);
const [, ctx] = mockedLogMicrodollarUsage.mock.calls[0];
expect(ctx.requested_model).toBe('kilo-auto/balanced');
});

it('does not bill when classifier cost is 0 (cache hit)', async () => {
mockedFetchEfficientAutoDecision.mockResolvedValue({
decision: {
Expand Down Expand Up @@ -816,20 +844,25 @@ describe('auto-routing shadow classifier', () => {
});
mockedEmitApiMetricsForResponse.mockReturnValue(undefined);
mockedAccountForMicrodollarUsage.mockReturnValue(undefined);
mockedApplyResolvedAutoModel.mockImplementation(async (_opts, request) => {
mockedApplyResolvedAutoModel.mockImplementation(async (opts, request) => {
if (opts.efficientDecision) await opts.efficientDecision();
request.body.model = 'openai/gpt-4o';
return { kind: 'ok', resolved: { model: 'openai/gpt-4o' } };
});
});

it('does not schedule a background classifier request for non-efficient auto models', async () => {
it('routes kilo-auto/balanced through the efficient classifier', async () => {
const { after: mockedAfter } = jest.requireMock<{ after: jest.Mock }>('next/server');
mockedFetchEfficientAutoDecision.mockResolvedValue({ decision: null, costUsd: 0 });

const { POST } = await import('./route');
const response = await POST(makeRequest(makeBody('kilo-auto/balanced')) as never);

expect(response.status).toBe(200);
expect(mockedUpstreamRequest).toHaveBeenCalledTimes(1);
expect(mockedFetchEfficientAutoDecision).toHaveBeenCalledWith(
expect.objectContaining({ requestedModel: 'kilo-auto/balanced' })
);
expect(mockedAfter).not.toHaveBeenCalled();
});
});
12 changes: 7 additions & 5 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import { isUnavailableModel } from '@/lib/ai-gateway/unavailable-models';
import { isCloudflareIP } from '@/lib/cloudflare-ip';
import {
isKiloAutoModel,
KILO_AUTO_BALANCED_MODEL,
KILO_AUTO_EFFICIENT_MODEL,
ORG_AUTO_MODEL,
} from '@/lib/ai-gateway/auto-model';
Expand Down Expand Up @@ -295,14 +296,15 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
if (isKiloAutoModel(requestedModelLowerCased)) {
autoModel = requestedModelLowerCased;
const efficientDecision =
requestedModelLowerCased === KILO_AUTO_EFFICIENT_MODEL.id
requestedModelLowerCased === KILO_AUTO_EFFICIENT_MODEL.id ||
requestedModelLowerCased === KILO_AUTO_BALANCED_MODEL.id
? async () => {
const { user, authFailedResponse, organizationId } = await authPromise;
// The classifier is a paid call on Kilo's own credential. Skip it
// for unauthenticated requests: kilo-auto/efficient resolves to a
// for unauthenticated requests: auto-routed models resolve to a
// paid model, so an unauthenticated caller is rejected downstream
// regardless, and a null decision simply falls back to balanced.
// This stops anonymous/abusive traffic from repeatedly spending
// This stops anonymous or abusive traffic from repeatedly spending
// Kilo-funded classification with no user to attribute it to.
if (!user || authFailedResponse) return null;
const { settings, plan } = await balanceAndSettingsPromise;
Expand Down Expand Up @@ -520,7 +522,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
fraudHeaders,
organizationId,
provider: 'openrouter',
requested_model: KILO_AUTO_EFFICIENT_MODEL.id,
requested_model: requestedModelLowerCased,
promptInfo: {
system_prompt_prefix: '',
system_prompt_length: 0,
Expand Down Expand Up @@ -554,7 +556,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
};
await logMicrodollarUsage(classifierStats, classifierContext);
} catch (error) {
console.error('Failed to bill classifier cost for kilo-auto/efficient', error);
console.error('Failed to bill classifier cost for auto routing', error);
}
})()
);
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/api/openrouter/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,12 @@ describe('GET /api/openrouter/models', () => {
models: ['poolside/laguna-m.1:free'],
},
},
balancedModel,
{
...balancedModel,
autoRouting: {
models: ['google/gemini-2.5-flash', 'openai/gpt-5.4-mini'],
},
},
geminiModel,
gptMiniModel,
poolsideModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ where
and mu.provider not in ('custom', 'direct-byok')
and mu.total_output_tokens > 0
and mu.is_user_byok = false
and mu.requested_model not ilike '%clawsetup%'
and mu.requested_model not ilike '%mercury-edit%'
group by 1, 2
order by 4 desc;
Expand Down
15 changes: 1 addition & 14 deletions apps/web/src/lib/ai-gateway/auto-model/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { z } from 'zod';
import {
CLAUDE_OPUS_CURRENT_MODEL_ID,
claude_sonnet_clawsetup_model,
CLAUDE_SONNET_CURRENT_MODEL_ID,
} from '@/lib/ai-gateway/providers/anthropic.constants';
import type { OpenRouterReasoningConfig } from '@/lib/ai-gateway/providers/openrouter/types';
Expand Down Expand Up @@ -34,8 +33,6 @@ export type ResolvedAutoModel = {
verbosity?: Verbosity;
};

export const KILO_AUTO_LEGACY_MODEL = 'kilo/auto'; // hardcoded in upstream OpenClaw

export const modeSchema = z.enum([
'claw',
'plan',
Expand Down Expand Up @@ -81,12 +78,6 @@ export const FRONTIER_MODE_TO_MODEL: Record<Mode, ResolvedAutoModel> = {
code: SONNET_FRONTIER,
};

export const BALANCED_CLAW_SETUP_MODEL: ResolvedAutoModel = {
model: claude_sonnet_clawsetup_model.public_id,
reasoning: { enabled: true, effort: 'high' },
verbosity: 'high',
};

// INVARIANT: the efficient static fallback must remain image-capable.
// The capability-aware routing filter relies on this guarantee to make
// image requests succeed even when no benchmark candidate is capable.
Expand Down Expand Up @@ -195,9 +186,5 @@ export const AUTO_MODELS = [
];

export function isKiloAutoModel(model: string) {
return (
AUTO_MODELS.some(m => m.id === model) ||
model === ORG_AUTO_MODEL.id ||
model === KILO_AUTO_LEGACY_MODEL
);
return AUTO_MODELS.some(m => m.id === model) || model === ORG_AUTO_MODEL.id;
}
20 changes: 16 additions & 4 deletions apps/web/src/lib/ai-gateway/auto-model/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@ jest.mock('@/lib/ai-gateway/providers/gateway-models-cache', () => ({
getOpenRouterModelsFromRedis: jest.fn(async () => new Set<string>()),
}));

jest.mock('@/lib/kiloclaw/setup-promo', () => ({
userIsWithinFirstKiloClawInstanceWindow: jest.fn(async () => false),
}));

import { resolveAutoModel } from './resolution';
import {
BALANCED_QWEN_MODEL,
FRONTIER_MODE_TO_MODEL,
KILO_AUTO_BALANCED_MODEL,
KILO_AUTO_EFFICIENT_MODEL,
ORG_AUTO_MODEL,
} from '@/lib/ai-gateway/auto-model';
Expand All @@ -38,6 +35,21 @@ const sampleDecision: AutoRoutingDecision = {
};

describe('resolveAutoModel — kilo-auto/efficient branch', () => {
it('resolves kilo-auto/balanced as an alias of kilo-auto/efficient', async () => {
const result = await resolveAutoModel(
{
...baseParams,
model: KILO_AUTO_BALANCED_MODEL.id,
apiKind: 'chat_completions',
efficientDecision: async () => sampleDecision,
},
nullUserPromise,
zeroBalancePromise
);

expect(result).toEqual({ kind: 'ok', resolved: { model: sampleDecision.model } });
});

it('resolves to decision.model when the thunk returns a decision', async () => {
const result = await resolveAutoModel(
{
Expand Down
21 changes: 3 additions & 18 deletions apps/web/src/lib/ai-gateway/auto-model/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,12 @@ import {
KILO_AUTO_BALANCED_MODEL,
KILO_AUTO_EFFICIENT_MODEL,
modeSchema,
BALANCED_CLAW_SETUP_MODEL,
BALANCED_QWEN_MODEL,
FRONTIER_MODE_TO_MODEL,
FRONTIER_CODE_MODEL,
type ResolvedAutoModel,
KILO_AUTO_LEGACY_MODEL,
ORG_AUTO_MODEL,
} from '@/lib/ai-gateway/auto-model';
import { userIsWithinFirstKiloClawInstanceWindow } from '@/lib/kiloclaw/setup-promo';
import {
autoFreeModels,
findKiloExclusiveModel,
Expand All @@ -54,8 +51,7 @@ type ResolveAutoModelParams = {
sessionId: string | null;
apiKind: GatewayRequest['kind'] | null;
clientIp: string | null;
// Lazily fetches the auto-routing worker's decision; only set for
// kilo-auto/efficient requests (route.ts owns the request-body capture).
// Lazily fetches the auto-routing worker's decision (route.ts owns the request-body capture).
efficientDecision?: () => Promise<AutoRoutingDecision | null>;
organizationContext?: Promise<{
organizationId?: string;
Expand Down Expand Up @@ -308,7 +304,7 @@ export async function resolveAutoModel(
},
};
}
if (model === KILO_AUTO_EFFICIENT_MODEL.id) {
if (model === KILO_AUTO_EFFICIENT_MODEL.id || model === KILO_AUTO_BALANCED_MODEL.id) {
const decision = params.efficientDecision ? await params.efficientDecision() : null;
if (decision && !isVirtualAutoModelId(decision.model)) {
const resolvedFromDecision = resolveEfficientDecisionModel(decision);
Expand All @@ -319,21 +315,10 @@ export async function resolveAutoModel(
// with implicit defaults — same balanced fallback as the no-decision path.
return { kind: 'ok', resolved: BALANCED_QWEN_MODEL };
}
// Static fallback when the worker is slow/unavailable: same model as
// balanced so an efficient request never degrades below balanced.
// Static fallback when the worker is slow or unavailable.
return { kind: 'ok', resolved: BALANCED_QWEN_MODEL };
}
const mode = resolveMode(modeHeader, featureHeader);
if (model === KILO_AUTO_BALANCED_MODEL.id || model === KILO_AUTO_LEGACY_MODEL) {
if (mode === 'claw' && featureHeader === 'kiloclaw') {
const user = await userPromise;
if (user && (await userIsWithinFirstKiloClawInstanceWindow({ userId: user.id }))) {
return { kind: 'ok', resolved: BALANCED_CLAW_SETUP_MODEL };
}
}

return { kind: 'ok', resolved: BALANCED_QWEN_MODEL };
}
return {
kind: 'ok',
resolved: (mode !== null ? FRONTIER_MODE_TO_MODEL[mode] : null) ?? FRONTIER_CODE_MODEL,
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/lib/ai-gateway/auto-routing-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,29 @@ describe('addAutoRoutingModels', () => {
...efficientModel,
autoRouting: { models: ['google/gemini-2.5-flash', 'openai/gpt-5.4-mini'] },
});
expect(result.slice(1)).toEqual([balancedModel, geminiModel, gptModel]);
expect(result.slice(1)).toEqual([
{
...balancedModel,
autoRouting: { models: ['google/gemini-2.5-flash', 'openai/gpt-5.4-mini'] },
},
geminiModel,
gptModel,
]);
});

test('annotates balanced as an alias of efficient routing', async () => {
const balancedModel = makeModel('kilo-auto/balanced');
const efficientModel = makeModel('kilo-auto/efficient');
const visibleModel = makeModel('google/gemini-2.5-flash');
mockedGetCachedRoutingTable.mockResolvedValue(routingTable([visibleModel.id]));

const result = await addAutoRoutingModels([balancedModel, efficientModel, visibleModel]);

expect(result).toEqual([
{ ...balancedModel, autoRouting: { models: [visibleModel.id] } },
{ ...efficientModel, autoRouting: { models: [visibleModel.id] } },
visibleModel,
]);
});

test('annotates the free auto model from its candidate source', async () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/ai-gateway/auto-routing-models.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { OpenRouterModelsResponse } from '@/lib/organizations/organization-types';
import { KILO_AUTO_EFFICIENT_MODEL, KILO_AUTO_FREE_MODEL } from '@/lib/ai-gateway/auto-model';
import {
KILO_AUTO_BALANCED_MODEL,
KILO_AUTO_EFFICIENT_MODEL,
KILO_AUTO_FREE_MODEL,
} from '@/lib/ai-gateway/auto-model';
import { getAutoFreeCandidates } from '@/lib/ai-gateway/auto-model/resolution';
import { isVirtualAutoModelId } from '@kilocode/auto-routing-contracts';
import { getCachedRoutingTable } from '@/lib/ai-gateway/auto-routing-table-cache';
Expand All @@ -15,6 +19,7 @@ export async function addAutoRoutingModels(
): Promise<OpenRouterModelsResponse['data']> {
const availableModelIds = new Set(models.map(model => model.id));
if (
!availableModelIds.has(KILO_AUTO_BALANCED_MODEL.id) &&
!availableModelIds.has(KILO_AUTO_EFFICIENT_MODEL.id) &&
!availableModelIds.has(KILO_AUTO_FREE_MODEL.id)
) {
Expand All @@ -34,6 +39,7 @@ export async function addAutoRoutingModels(
);
const freeModelIds = visibleConcreteModelIds(autoFreeCandidates, availableModelIds);
const autoRoutingChoices = new Map([
[KILO_AUTO_BALANCED_MODEL.id, efficientModelIds],
[KILO_AUTO_EFFICIENT_MODEL.id, efficientModelIds],
[KILO_AUTO_FREE_MODEL.id, freeModelIds],
]);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/ai-gateway/auto-routing-table-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const ROUTING_TABLE_TTL_MS = 5 * 60 * 1000;
* listing (org endpoint and the tRPC settings query), so an uncached admin-worker
* round-trip per request is wasteful. `createCachedFetch` also serves the
* last-known-good table when a refresh throws, so a transient worker outage does
* not blank the Auto Efficient choices shown in the UI.
* not blank the Auto Balanced and Auto Efficient choices shown in the UI.
*/
export const getCachedRoutingTable = createCachedFetch(
async () => {
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/lib/ai-gateway/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
claude_opus_4_7_stealth_model,
claude_sonnet_4_6_stealth_model,
claude_opus_4_6_stealth_model,
claude_sonnet_clawsetup_model,
CLAUDE_SONNET_CURRENT_MODEL_ID,
CLAUDE_OPUS_CURRENT_MODEL_ID,
} from '@/lib/ai-gateway/providers/anthropic.constants';
Expand Down Expand Up @@ -115,7 +114,6 @@ export const kiloExclusiveModels = [
...deepseekDiscountedModels,
qwen36_plus_stealth_model,
gpt_5_6_sol_stealth_model,
claude_sonnet_clawsetup_model,
claude_opus_4_8_stealth_model,
claude_opus_4_7_stealth_model,
claude_sonnet_4_6_stealth_model,
Expand Down
14 changes: 0 additions & 14 deletions apps/web/src/lib/ai-gateway/providers/anthropic.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,6 @@ export const claude_opus_4_6_stealth_model: KiloExclusiveModel = {
inference_provider_restriction: [],
};

export const claude_sonnet_clawsetup_model: KiloExclusiveModel = {
public_id: CLAUDE_SONNET_CURRENT_MODEL_ID + ':clawsetup',
internal_id: CLAUDE_SONNET_CURRENT_MODEL_ID,
display_name: 'Claude Sonnet KiloClaw Setup Promo',
description: 'Claude Sonnet KiloClaw Setup Promo',
status: 'hidden', // only usable through kilo-auto
context_length: 1_000_000,
max_completion_tokens: 128_000,
gateway: 'openrouter',
flags: ['reasoning', 'vision', 'vercel-routing'],
pricing: null,
inference_provider_restriction: [],
};

export function isClaudeModel(requestedModel: string) {
return requestedModel.includes('claude');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,10 @@ describe('mapModelIdToVercel', () => {
});

it('does not use internal_id for exclusives that are not vercel-routed', () => {
// claude_sonnet_clawsetup_model has gateway 'openrouter' and no
// claude_sonnet_4_6_stealth_model has gateway 'martian' and no
// 'vercel-routing' flag, so the mapping must pass the public id through
// the generic prefix rewrite instead of substituting internal_id.
expect(mapModelIdToVercel('anthropic/claude-sonnet-4.6:clawsetup')).toBe(
'anthropic/claude-sonnet-4.6:clawsetup'
);
expect(mapModelIdToVercel('stealth/claude-sonnet-4.6')).toBe('stealth/claude-sonnet-4.6');
});

it('does not use internal_id for disabled exclusives even when vercel-routed', () => {
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/lib/ai-gateway/unavailable-models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { claude_sonnet_clawsetup_model } from '@/lib/ai-gateway/providers/anthropic.constants';
import { normalizeModelId } from '@/lib/ai-gateway/model-utils';

const unavailableModelIds: ReadonlySet<string> = new Set([
Expand Down Expand Up @@ -55,7 +54,6 @@ const unavailableModelIds: ReadonlySet<string> = new Set([
'z-ai/glm-4.7:free',
'stepfun/step-3.5-flash:free',
'z-ai/glm-5:free',
Comment thread
iscekic marked this conversation as resolved.
claude_sonnet_clawsetup_model.public_id, // only usable through kilo-auto
]);

export function isUnavailableModel(modelId: string): boolean {
Expand Down
Loading
Loading