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
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from '@jest/globals';
import { CLAUDE_OPUS_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/anthropic.constants';
import { applyGatewayModelsFallback } from '@/lib/ai-gateway/providers/apply-provider-specific-logic';
import {
applyOpenRouterStructuredOutputRouting,
applyGatewayModelsFallback,
} from '@/lib/ai-gateway/providers/apply-provider-specific-logic';
import type { GatewayRequest } from '@/lib/ai-gateway/providers/openrouter/types';
import type { ProviderId } from '@/lib/ai-gateway/providers/types';

Expand Down Expand Up @@ -46,3 +49,51 @@ describe('applyGatewayModelsFallback', () => {
expect(request.body.models).toBeUndefined();
});
});

describe('applyOpenRouterStructuredOutputRouting', () => {
it('requires structured-output support from OpenRouter providers', () => {
const request = makeStructuredOutputRequest();
request.body.provider = { only: ['anthropic'] };

applyOpenRouterStructuredOutputRouting('openrouter', request);

expect(request.body.provider).toEqual({
only: ['anthropic'],
require_parameters: true,
});
});

it.each<ProviderId>(['direct-byok', 'custom', 'experiment', 'vercel'])(
'does not send OpenRouter routing controls to %s',
providerId => {
const request = makeStructuredOutputRequest();

applyOpenRouterStructuredOutputRouting(providerId, request);

expect(request.body.provider).toBeUndefined();
}
);
});

function makeStructuredOutputRequest(): GatewayRequest {
return {
kind: 'chat_completions',
body: {
model: 'anthropic/claude-sonnet-4.6',
messages: [{ role: 'user', content: 'hello' }],
response_format: {
type: 'json_schema',
json_schema: {
name: 'result',
strict: true,
schema: {
type: 'object',
properties: { result: { type: 'string' } },
required: ['result'],
additionalProperties: false,
},
},
},
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ export function applyGatewayModelsFallback(
delete requestToMutate.body.models;
}

export function applyOpenRouterStructuredOutputRouting(
providerId: ProviderId,
requestToMutate: GatewayRequest
): void {
if (
providerId !== 'openrouter' ||
requestToMutate.kind !== 'chat_completions' ||
requestToMutate.body.response_format?.type !== 'json_schema'
) {
return;
}

requestToMutate.body.provider = {
...requestToMutate.body.provider,
require_parameters: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in what scenarios does setting this parameter make a difference?

};
}

export function applyProviderSpecificLogic(
provider: Provider,
requestedModel: string,
Expand All @@ -124,6 +142,7 @@ export function applyProviderSpecificLogic(
taskId: string | null
) {
applyGatewayModelsFallback(provider.id, requestedModel, requestToMutate);
applyOpenRouterStructuredOutputRouting(provider.id, requestToMutate);
applyTrackingIds(requestToMutate, provider, userId, taskId);

sanitizeBinaryToolResults(requestToMutate);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/ai-gateway/providers/openrouter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type OpenRouterProviderConfig = {
ignore?: string[];
data_collection?: 'allow' | 'deny';
zdr?: boolean;
require_parameters?: boolean;
};

export type VercelInferenceProviderConfig = { apiKey: string; baseURL?: string } | AwsCredentials;
Expand Down
32 changes: 20 additions & 12 deletions apps/web/src/lib/code-reviews/review-memory/aggregation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { generateText } from 'ai';
import * as z from 'zod';

import type { CodeReviewFeedbackEvent } from '@kilocode/db/schema';
Expand All @@ -7,7 +6,7 @@ import type { ReviewMemoryOwner } from './db';
import { listRecentFeedbackEvents, upsertScopeProposal } from './db';
import {
createReviewMemoryGatewayProvider,
extractReviewMemoryJsonObject,
generateReviewMemoryStructuredOutput,
resolveReviewMemoryActor,
resolveReviewMemoryModel,
} from './llm';
Expand All @@ -30,6 +29,17 @@ const ReviewMemoryProposalDraftSchema = z.discriminatedUnion('status', [
}),
]);

const ReviewMemoryProposalWireSchema = z.object({
status: z.enum(['no_change', 'propose']),
title: z.string().nullable(),
rationale: z.string().nullable(),
proposedMarkdown: z.string().nullable(),
positiveCount: z.number().int(),
negativeCount: z.number().int(),
neutralCount: z.number().int(),
evidenceEventIds: z.array(z.string()),
});

export type ReviewMemoryProposalDraft = z.infer<typeof ReviewMemoryProposalDraftSchema>;

export type GenerateReviewMemoryProposal = (input: {
Expand Down Expand Up @@ -63,18 +73,20 @@ export async function generateReviewMemoryProposalWithGateway(input: {
actor,
userAgent: 'Kilo Review Memory Analyzer',
});
const result = await generateText({
const result = await generateReviewMemoryStructuredOutput({
model: provider.chatModel(modelSlug),
prompt: buildReviewMemoryAnalysisPrompt(input),
maxOutputTokens: 4_000,
schemaName: 'review_memory_proposal',
schema: ReviewMemoryProposalDraftSchema,
wireSchema: ReviewMemoryProposalWireSchema,
validate: validateReviewMemoryProposalDraft,
});

return {
draft: validateReviewMemoryProposalDraft(
ReviewMemoryProposalDraftSchema.parse(extractReviewMemoryJsonObject(result.text))
),
tokensIn: result.usage.inputTokens ?? null,
tokensOut: result.usage.outputTokens ?? null,
draft: result.output,
tokensIn: result.tokensIn,
tokensOut: result.tokensOut,
};
}

Expand Down Expand Up @@ -143,10 +155,6 @@ function buildReviewMemoryAnalysisPrompt(input: {

return `You analyze maintainer replies to Kilo's automated code-review comments for one repository.

Return strict JSON in one of these shapes:
{"status":"no_change"}
{"status":"propose","title":"short proposal title","rationale":"why this guidance is justified","proposedMarkdown":"standalone REVIEW.md guidance","positiveCount":0,"negativeCount":0,"neutralCount":0,"evidenceEventIds":["event ids"]}

Rules:
- Classify each maintainer reply as positive, negative, or neutral.
- Propose the smallest possible REVIEW.md change only when there is a clear, repeated pattern.
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/lib/code-reviews/review-memory/llm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { User } from '@kilocode/db/schema';
import * as z from 'zod';

import { createReviewMemoryGatewayProvider, generateReviewMemoryStructuredOutput } from './llm';

const TestOutputSchema = z.object({
status: z.literal('ok'),
value: z.string(),
});

describe('Review Memory structured model output', () => {
it('sends JSON Schema through the gateway and returns typed output', async () => {
let requestBody: unknown;
const gatewayFetch: typeof fetch = async (_request, init) => {
if (typeof init?.body !== 'string') throw new Error('Expected a JSON request body.');
requestBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
id: 'gateway-response-id',
object: 'chat.completion',
created: 1_750_204_800,
model: 'test/model',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: '{"status":"ok","value":"accepted"}',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 5,
total_tokens: 17,
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
}
);
};
const actor = {
id: 'test-user-id',
api_token_pepper: null,
} as User;
const provider = createReviewMemoryGatewayProvider({
owner: { type: 'user', id: actor.id },
actor,
userAgent: 'Review Memory structured output test',
fetch: gatewayFetch,
});

const result = await generateReviewMemoryStructuredOutput({
model: provider.chatModel('test/model'),
prompt: 'Return a test result.',
maxOutputTokens: 100,
schemaName: 'test_review_memory_output',
schema: TestOutputSchema,
validate: output => output,
});

expect(result).toEqual({
output: { status: 'ok', value: 'accepted' },
tokensIn: 12,
tokensOut: 5,
});
expect(requestBody).toEqual(
expect.objectContaining({
response_format: expect.objectContaining({
type: 'json_schema',
json_schema: expect.objectContaining({ strict: true }),
}),
})
);
});
});
Loading