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
16 changes: 6 additions & 10 deletions apps/web/src/lib/ai-gateway/auto-model/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import {
} from '@/lib/organizations/organization-auto-model';
import { getModelVariants } from '@/lib/ai-gateway/providers/model-settings';
import type { OpenCodeVariant } from '@kilocode/db/schema-types';
import type { OpenRouterReasoningConfig } from '@/lib/ai-gateway/providers/openrouter/types';

type ResolveAutoModelParams = {
model: string;
Expand Down Expand Up @@ -238,23 +237,20 @@ async function resolveOrganizationAutoModel(
* falls back to balanced rather than serving implicit defaults. When `variant`
* is absent, preserve legacy effort-only behavior for rolling deploys.
*/
function resolveEfficientDecisionModel(decision: AutoRoutingDecision): ResolvedAutoModel | null {
async function resolveEfficientDecisionModel(
decision: AutoRoutingDecision
): Promise<ResolvedAutoModel | null> {
// `variant` is only on the benchmark decision branch of the discriminated
// union; coding-plan defaults never carry it.
if ('variant' in decision && decision.variant != null) {
const variants = getModelVariants(decision.model);
const variants = await getModelVariants(decision.model);
const variantSettings: OpenCodeVariant | undefined = variants?.[decision.variant];
if (!variantSettings) {
return null;
}
// Catalog variants are the source of truth; cast into ResolvedAutoModel's
// OpenRouter-shaped fields (catalog effort may include values like `max`
// beyond ChatCompletionReasoningEffort).
return {
model: decision.model,
...(variantSettings.reasoning
? { reasoning: { ...variantSettings.reasoning } as OpenRouterReasoningConfig }
: {}),
...(variantSettings.reasoning ? { reasoning: { ...variantSettings.reasoning } } : {}),
Comment thread
chrarnoldus marked this conversation as resolved.
...(variantSettings.verbosity ? { verbosity: variantSettings.verbosity } : {}),
};
}
Expand Down Expand Up @@ -307,7 +303,7 @@ export async function resolveAutoModel(
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);
const resolvedFromDecision = await resolveEfficientDecisionModel(decision);
if (resolvedFromDecision) {
return { kind: 'ok', resolved: resolvedFromDecision };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {
REASONING_VARIANTS_BINARY,
REASONING_VARIANTS_MINIMAL_LOW_MEDIUM_HIGH,
} from '@/lib/ai-gateway/providers/model-settings';
import { isReasoningExplicitlyDisabled } from '@/lib/ai-gateway/providers/openrouter/request-helpers';
import type {
DirectByokModel,
DirectByokProvider,
} from '@/lib/ai-gateway/providers/direct-byok/types';
import {
REASONING_VARIANTS_BINARY,
REASONING_VARIANTS_MINIMAL_LOW_MEDIUM_HIGH,
} from '@/lib/ai-gateway/providers/variants';

export const BYTEPLUS_CODING_PROVIDER_ID = 'byteplus-coding';

Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/lib/ai-gateway/providers/direct-byok/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ jest.mock('@/lib/ai-gateway/models', () => ({

jest.mock('@/lib/ai-gateway/providers/model-settings', () => ({
getAiSdkProvider: jest.fn(),
getModelVariants: jest.fn(),
}));

jest.mock('@/lib/ai-gateway/providers/variants', () => ({
getFallbackModelVariants: jest.fn(),
}));

jest.mock('./direct-byok-definitions', () => ({
Expand Down Expand Up @@ -124,20 +127,20 @@ describe('getDirectByokModel', () => {
test('falls back to model-name variants when synced variants are unavailable', async () => {
const { getDirectByokModelsForUser } = await loadDirectByokModule();
const { getBYOKforUser } = await import('@/lib/ai-gateway/byok');
const { getModelVariants } = await import('@/lib/ai-gateway/providers/model-settings');
const { getFallbackModelVariants } = await import('@/lib/ai-gateway/providers/variants');
const fallback = { thinking: { reasoning: { enabled: true, effort: 'high' as const } } };
jest
.mocked(getBYOKforUser)
.mockResolvedValueOnce([{ providerId: 'chutes-byok', decryptedAPIKey: 'test-key' }]);
jest.mocked(getModelVariants).mockReturnValue(fallback);
jest.mocked(getFallbackModelVariants).mockReturnValue(fallback);

const models = await getDirectByokModelsForUser('user-id');

expect(models[0].opencode.variants).toEqual({
high: { reasoning: { enabled: true, effort: 'high' } },
});
expect(models[1].opencode.variants).toBe(fallback);
expect(getModelVariants).toHaveBeenCalledTimes(1);
expect(getModelVariants).toHaveBeenCalledWith('chutes-byok/non-reasoning-model');
expect(getFallbackModelVariants).toHaveBeenCalledTimes(1);
expect(getFallbackModelVariants).toHaveBeenCalledWith('chutes-byok/non-reasoning-model');
});
});
5 changes: 3 additions & 2 deletions apps/web/src/lib/ai-gateway/providers/direct-byok/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { readDb } from '@/lib/drizzle';
import { preferredModels } from '@/lib/ai-gateway/models';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { OpenCodeSettings } from '@kilocode/db';
import { getAiSdkProvider, getModelVariants } from '@/lib/ai-gateway/providers/model-settings';
import { getAiSdkProvider } from '@/lib/ai-gateway/providers/model-settings';
import { getFallbackModelVariants } from '@/lib/ai-gateway/providers/variants';

export function formatDirectByokModelId(provider: DirectByokProvider, model: DirectByokModel) {
return (provider.id + '/' + model.id).toLowerCase();
Expand Down Expand Up @@ -62,7 +63,7 @@ function convertModel(
hasUserByokAvailable: true,
opencode: {
ai_sdk_provider: getAiSdkProvider(id, provider.id) ?? provider.default_ai_sdk_provider,
variants: model.variants ?? getModelVariants(id),
variants: model.variants ?? getFallbackModelVariants(id),
} satisfies OpenCodeSettings,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import {
VerbositySchema,
type OpenCodeSettings,
} from '@kilocode/db/schema-types';
import { getAiSdkProvider } from '@/lib/ai-gateway/providers/model-settings';
import {
getAiSdkProvider,
getModelVariants,
getFallbackModelVariants,
REASONING_VARIANTS_BINARY,
} from '@/lib/ai-gateway/providers/model-settings';
} from '@/lib/ai-gateway/providers/variants';

const DEFAULT_CONTENT_LENGTH = 200_000;
const DEFAULT_MAX_COMPLETION_TOKENS = 32_000;
Expand Down Expand Up @@ -196,7 +196,7 @@ export function parseModelsDevProviderModels(
const aiSdkProvider = getAiSdkProvider(modelId, providerId);
const variants =
modelsDevReasoningOptionsToVariants(model.reasoning_options ?? []) ??
getModelVariants(modelId);
getFallbackModelVariants(modelId);
return {
id: model.id,
name: shortenDisplayName(model.name),
Expand Down
159 changes: 39 additions & 120 deletions apps/web/src/lib/ai-gateway/providers/model-settings.ts
Original file line number Diff line number Diff line change
@@ -1,134 +1,51 @@
import { isClaudeModel } from '@/lib/ai-gateway/providers/anthropic.constants';
import { isGemini3Model, isGemmaModel } from '@/lib/ai-gateway/providers/google';
import { isKimiModel } from '@/lib/ai-gateway/providers/moonshotai';
import { isOpenAiModel } from '@/lib/ai-gateway/providers/openai';
import { isQwenModel } from '@/lib/ai-gateway/providers/qwen';
import { isGrokModel, isGrok42Model, isGrok45Model } from '@/lib/ai-gateway/providers/xai';
import { isGlmModel } from '@/lib/ai-gateway/providers/zai';
import { isGrokModel } from '@/lib/ai-gateway/providers/xai';
import type {
CustomLlmProvider,
OpenCodePrompt,
OpenCodeSettings,
OpenCodeVariant,
} from '@kilocode/db/schema-types';
import { isStepModel } from '@/lib/ai-gateway/providers/stepfun';
import { ReasoningEffortSchema } from '@kilocode/db/schema-types';
import { isDeepseekModel } from '@/lib/ai-gateway/providers/deepseek';
import { VerbositySchema } from '@kilocode/db/schema-types';
import { isMinimaxModel } from '@/lib/ai-gateway/providers/minimax';
import type { DirectUserByokInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { isMuseModel } from '@/lib/ai-gateway/providers/meta';
import { getOpenRouterModelsMetadataFromDatabase } from '@/lib/ai-gateway/providers/gateway-models-cache';
Comment thread
chrarnoldus marked this conversation as resolved.
import {
getFallbackModelVariants,
REASONING_VARIANTS_THINKING_ONLY,
REASONING_VARIANTS_BINARY,
} from '@/lib/ai-gateway/providers/variants';

const REASONING_VARIANTS_THINKING_ONLY = {
thinking: { reasoning: { enabled: true, effort: 'high' } },
} as const;

export const REASONING_VARIANTS_BINARY = {
instant: { reasoning: { enabled: false, effort: 'none' } },
...REASONING_VARIANTS_THINKING_ONLY,
} as const;

export const REASONING_VARIANTS_LOW_MEDIUM_HIGH = {
low: { reasoning: { enabled: true, effort: 'low' } },
medium: { reasoning: { enabled: true, effort: 'medium' } },
high: { reasoning: { enabled: true, effort: 'high' } },
} as const;

export const REASONING_VARIANTS_MAX_HIGH_LOW = {
max: { reasoning: { enabled: true, effort: 'max' } },
high: { reasoning: { enabled: true, effort: 'high' } },
low: { reasoning: { enabled: true, effort: 'low' } },
} as const;

export const REASONING_VARIANTS_XHIGH_MEDIUM_LOW = {
xhigh: { reasoning: { enabled: true, effort: 'xhigh' } },
medium: { reasoning: { enabled: true, effort: 'medium' } },
low: { reasoning: { enabled: true, effort: 'low' } },
} as const;

export const REASONING_VARIANTS_MINIMAL_LOW_MEDIUM_HIGH = {
minimal: { reasoning: { enabled: true, effort: 'minimal' } },
...REASONING_VARIANTS_LOW_MEDIUM_HIGH,
} as const;

export const REASONING_VARIANTS_NONE_MINIMAL_LOW_MEDIUM_HIGH = {
none: { reasoning: { enabled: false, effort: 'none' } },
...REASONING_VARIANTS_MINIMAL_LOW_MEDIUM_HIGH,
} as const;

export const REASONING_VARIANTS_NONE_HIGH_XHIGH = {
none: { reasoning: { enabled: false, effort: 'none' } },
high: { reasoning: { enabled: true, effort: 'high' } },
xhigh: { reasoning: { enabled: true, effort: 'xhigh' } },
} as const;

const REASONING_VARIANTS_CLAUDE = {
none: { reasoning: { enabled: false, effort: 'none' } },
low: { reasoning: { enabled: true, effort: 'low' }, verbosity: 'low' },
medium: { reasoning: { enabled: true, effort: 'medium' }, verbosity: 'medium' },
high: { reasoning: { enabled: true, effort: 'high' }, verbosity: 'high' },
xhigh: { reasoning: { enabled: true, effort: 'xhigh' }, verbosity: 'xhigh' },
max: { reasoning: { enabled: true, effort: 'xhigh' }, verbosity: 'max' },
} as const;

export const REASONING_VARIANTS_INSTANT_LOW_MEDIUM_HIGH = {
instant: REASONING_VARIANTS_BINARY.instant,
...REASONING_VARIANTS_LOW_MEDIUM_HIGH,
} as const;
export async function getOpenRouterDerivedModelVariants(
model: string
): Promise<OpenCodeSettings['variants']> {
const models = await getOpenRouterModelsMetadataFromDatabase();
const reasoning = models[model]?.reasoning;
if (!reasoning) {
return undefined;
}
if (!reasoning.supported_efforts?.length) {
return reasoning.mandatory ? REASONING_VARIANTS_THINKING_ONLY : REASONING_VARIANTS_BINARY;
}
const useAnthropicProvider = getAiSdkProvider(model, null) === 'anthropic';
const variants: [string, OpenCodeVariant][] = reasoning.supported_efforts.map(effort => [
effort,
{
reasoning: { enabled: true, effort },
verbosity: useAnthropicProvider ? VerbositySchema.safeParse(effort).data : undefined,
},
]);
if (!reasoning.mandatory) {
variants.push(['none', { reasoning: { enabled: false, effort: 'none' } }]);
}
return Object.fromEntries(variants);
}

export function getModelVariants(model: string): OpenCodeSettings['variants'] {
if (isClaudeModel(model)) {
return REASONING_VARIANTS_CLAUDE;
}
if (model.includes('codex') || isGemini3Model(model)) {
return Object.fromEntries(
ReasoningEffortSchema.options
.filter(e => e !== 'none' && e !== 'minimal' && e !== 'max')
.map(effort => [effort, { reasoning: { enabled: true, effort } }])
);
}
if (isOpenAiModel(model)) {
return Object.fromEntries(
ReasoningEffortSchema.options
.filter(e => e !== 'minimal')
.map(effort => [effort, { reasoning: { enabled: effort !== 'none', effort } }])
);
}
if (model.includes('mistral-medium-3-5')) {
return REASONING_VARIANTS_BINARY;
}
if (model.includes('kimi-k2.7-code')) {
return REASONING_VARIANTS_THINKING_ONLY;
}
if (model.includes('kimi-k2')) {
return REASONING_VARIANTS_BINARY;
}
if (isKimiModel(model)) {
return REASONING_VARIANTS_MAX_HIGH_LOW;
}
if (model.includes('qwen3.8') && (model.includes('plus') || model.includes('max'))) {
return REASONING_VARIANTS_XHIGH_MEDIUM_LOW;
}
if (
isMinimaxModel(model) ||
isGrok42Model(model) ||
isQwenModel(model) ||
isGemmaModel(model) ||
model.includes('mimo')
) {
return REASONING_VARIANTS_BINARY;
}
if (model.startsWith('inception/mercury-2')) {
return REASONING_VARIANTS_INSTANT_LOW_MEDIUM_HIGH;
}
if (isStepModel(model) || isGrok45Model(model)) {
return REASONING_VARIANTS_LOW_MEDIUM_HIGH;
}
if (isDeepseekModel(model) || isGlmModel(model)) {
return REASONING_VARIANTS_NONE_HIGH_XHIGH;
}
if (isMuseModel(model)) {
return REASONING_VARIANTS_NONE_MINIMAL_LOW_MEDIUM_HIGH;
}
return undefined;
export async function getModelVariants(model: string): Promise<OpenCodeSettings['variants']> {
return (await getOpenRouterDerivedModelVariants(model)) ?? getFallbackModelVariants(model);
}

export function getAiSdkProvider(
Expand Down Expand Up @@ -162,9 +79,11 @@ function getOpenCodePrompt(model: string): OpenCodePrompt | undefined {
return undefined;
}

export function getGatewayOpenCodeSettings(model: string): OpenCodeSettings | undefined {
export async function getGatewayOpenCodeSettings(
model: string
): Promise<OpenCodeSettings | undefined> {
const ai_sdk_provider = getAiSdkProvider(model, null);
const variants = getModelVariants(model);
const variants = await getModelVariants(model);
const prompt = getOpenCodePrompt(model);
return { ai_sdk_provider, variants, prompt };
}
2 changes: 1 addition & 1 deletion apps/web/src/lib/ai-gateway/providers/openrouter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ async function enhancedModelList(models: OpenRouterModel[]) {
preferredIndex: preferredIndex >= 0 ? preferredIndex : undefined,
isFree: model.isFree ?? isFree,
mayTrainOnYourPrompts: model.mayTrainOnYourPrompts ?? isFree,
opencode: model.opencode ?? getGatewayOpenCodeSettings(model.id),
opencode: model.opencode ?? (await getGatewayOpenCodeSettings(model.id)),
architecture: addPdf
? {
...model.architecture,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/ai-gateway/providers/openrouter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function isDataCollectionExplicitlyDisallowed(
}

export type OpenRouterReasoningConfig = {
effort?: OpenAI.Chat.Completions.ChatCompletionReasoningEffort | 'none';
effort?: OpenAI.Chat.Completions.ChatCompletionReasoningEffort;
max_tokens?: number;
exclude?: boolean;
enabled?: boolean;
Expand Down
Loading