diff --git a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts index 1e091d6c3c..109650f47a 100644 --- a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts @@ -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'; @@ -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(['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, + }, + }, + }, + }, + }; +} diff --git a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts index 584e42f5bb..bd2682365a 100644 --- a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts +++ b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts @@ -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, + }; +} + export function applyProviderSpecificLogic( provider: Provider, requestedModel: string, @@ -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); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/types.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/types.ts index 3656e00757..fddec5da95 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/types.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/types.ts @@ -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; diff --git a/apps/web/src/lib/code-reviews/review-memory/aggregation.ts b/apps/web/src/lib/code-reviews/review-memory/aggregation.ts index 5483f39eab..69bee7d136 100644 --- a/apps/web/src/lib/code-reviews/review-memory/aggregation.ts +++ b/apps/web/src/lib/code-reviews/review-memory/aggregation.ts @@ -1,4 +1,3 @@ -import { generateText } from 'ai'; import * as z from 'zod'; import type { CodeReviewFeedbackEvent } from '@kilocode/db/schema'; @@ -7,7 +6,7 @@ import type { ReviewMemoryOwner } from './db'; import { listRecentFeedbackEvents, upsertScopeProposal } from './db'; import { createReviewMemoryGatewayProvider, - extractReviewMemoryJsonObject, + generateReviewMemoryStructuredOutput, resolveReviewMemoryActor, resolveReviewMemoryModel, } from './llm'; @@ -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; export type GenerateReviewMemoryProposal = (input: { @@ -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, }; } @@ -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. diff --git a/apps/web/src/lib/code-reviews/review-memory/llm.test.ts b/apps/web/src/lib/code-reviews/review-memory/llm.test.ts new file mode 100644 index 0000000000..ebac2baadd --- /dev/null +++ b/apps/web/src/lib/code-reviews/review-memory/llm.test.ts @@ -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 }), + }), + }) + ); + }); +}); diff --git a/apps/web/src/lib/code-reviews/review-memory/llm.ts b/apps/web/src/lib/code-reviews/review-memory/llm.ts index 8f23c333fc..e4f003f859 100644 --- a/apps/web/src/lib/code-reviews/review-memory/llm.ts +++ b/apps/web/src/lib/code-reviews/review-memory/llm.ts @@ -1,4 +1,5 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { generateText, jsonSchema, Output, zodSchema, type LanguageModel } from 'ai'; import * as z from 'zod'; import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; @@ -12,10 +13,49 @@ import type { User } from '@kilocode/db/schema'; import type { ReviewMemoryPlatform } from '@kilocode/db/schema-types'; import type { ReviewMemoryOwner } from './db'; +// Claude rejects these constraints at the provider boundary; the source Zod schema still validates output locally. +const UNSUPPORTED_WIRE_SCHEMA_KEYWORDS = new Set([ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'maxItems', + 'uniqueItems', + 'contains', + 'minContains', + 'maxContains', + 'minProperties', + 'maxProperties', + 'patternProperties', + 'propertyNames', + 'dependencies', + 'dependentRequired', + 'dependentSchemas', + 'unevaluatedProperties', + 'unevaluatedItems', + 'not', + 'if', + 'then', + 'else', +]); + const ReviewMemoryModelConfigSchema = z.object({ model_slug: z.string().optional(), }); +type GenerateReviewMemoryStructuredOutputInput = { + model: LanguageModel; + prompt: string; + maxOutputTokens: number; + schemaName: string; + schema: z.ZodType; + wireSchema?: z.ZodType; + validate: (output: OUTPUT) => OUTPUT; +}; + export async function resolveReviewMemoryActor(owner: ReviewMemoryOwner): Promise { if (owner.type === 'org') { return await ensureBotUserForOrg(owner.id, 'code-review'); @@ -43,6 +83,7 @@ export function createReviewMemoryGatewayProvider(input: { owner: ReviewMemoryOwner; actor: User; userAgent: string; + fetch?: typeof globalThis.fetch; }) { const headers: Record = { 'User-Agent': input.userAgent, @@ -57,16 +98,79 @@ export function createReviewMemoryGatewayProvider(input: { baseURL: `${APP_URL}/api/openrouter`, apiKey: generateApiToken(input.actor, { internalApiUse: true }), headers, + fetch: input.fetch, + supportsStructuredOutputs: true, }); } -export function extractReviewMemoryJsonObject(text: string): unknown { - const trimmed = text.trim(); - if (trimmed.startsWith('{') && trimmed.endsWith('}')) return JSON.parse(trimmed); - const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); - if (fenced?.[1]) return JSON.parse(fenced[1]); - const start = trimmed.indexOf('{'); - const end = trimmed.lastIndexOf('}'); - if (start >= 0 && end > start) return JSON.parse(trimmed.slice(start, end + 1)); - throw new Error('Review Memory model did not return JSON'); +export async function generateReviewMemoryStructuredOutput( + input: GenerateReviewMemoryStructuredOutputInput +): Promise<{ + output: OUTPUT; + tokensIn: number | null; + tokensOut: number | null; +}> { + const sourceSchema = zodSchema(input.schema); + const providerSchema = zodSchema(input.wireSchema ?? input.schema); + const wireSchema = jsonSchema( + Promise.resolve(providerSchema.jsonSchema).then(schema => { + const transformed = structuredClone(schema); + transformReviewMemoryWireSchema(transformed); + return transformed; + }), + { validate: sourceSchema.validate } + ); + + const result = await generateText({ + model: input.model, + prompt: input.prompt, + maxOutputTokens: input.maxOutputTokens, + output: Output.object({ + schema: wireSchema, + name: input.schemaName, + }), + }); + + return { + output: input.validate(result.output), + tokensIn: result.usage.inputTokens ?? null, + tokensOut: result.usage.outputTokens ?? null, + }; +} + +function transformReviewMemoryWireSchema(schema: unknown): void { + const pending: unknown[] = [schema]; + + while (pending.length > 0) { + const value = pending.pop(); + if (Array.isArray(value)) { + pending.push(...value); + continue; + } + if (!isRecord(value)) continue; + + if (Array.isArray(value.oneOf)) { + const existingAnyOf = Array.isArray(value.anyOf) ? value.anyOf : []; + value.anyOf = [...existingAnyOf, ...value.oneOf]; + delete value.oneOf; + } + if (value.type === 'object') { + value.additionalProperties = false; + } + + for (const [key, nestedValue] of Object.entries(value)) { + if ( + UNSUPPORTED_WIRE_SCHEMA_KEYWORDS.has(key) || + (key === 'minItems' && nestedValue !== 0 && nestedValue !== 1) + ) { + delete value[key]; + continue; + } + pending.push(nestedValue); + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; } diff --git a/apps/web/src/lib/code-reviews/review-memory/review-md-integration.ts b/apps/web/src/lib/code-reviews/review-memory/review-md-integration.ts index e6b34e5bba..80c196f3f1 100644 --- a/apps/web/src/lib/code-reviews/review-memory/review-md-integration.ts +++ b/apps/web/src/lib/code-reviews/review-memory/review-md-integration.ts @@ -1,4 +1,3 @@ -import { generateText } from 'ai'; import * as z from 'zod'; import type { CodeReviewMemoryProposal } from '@kilocode/db/schema'; @@ -6,7 +5,7 @@ import type { ReviewMemoryPlatform } from '@kilocode/db/schema-types'; import type { ReviewMemoryOwner } from './db'; import { createReviewMemoryGatewayProvider, - extractReviewMemoryJsonObject, + generateReviewMemoryStructuredOutput, resolveReviewMemoryActor, resolveReviewMemoryModel, } from './llm'; @@ -53,18 +52,19 @@ export async function generateIntegratedReviewGuidanceWithGateway(input: { userAgent: 'Kilo Review Memory Integrator', }); - const result = await generateText({ + const result = await generateReviewMemoryStructuredOutput({ model: provider.chatModel(modelSlug), prompt: buildReviewMdIntegrationPrompt(input), maxOutputTokens: 8_000, + schemaName: 'review_md_integration', + schema: ReviewMdIntegrationOutputSchema, + validate: validateReviewMdIntegrationOutput, }); - const parsed = ReviewMdIntegrationOutputSchema.parse(extractReviewMemoryJsonObject(result.text)); - const validated = validateReviewMdIntegrationOutput(parsed); return { - ...validated, - tokensIn: result.usage.inputTokens ?? null, - tokensOut: result.usage.outputTokens ?? null, + ...result.output, + tokensIn: result.tokensIn, + tokensOut: result.tokensOut, }; } @@ -103,9 +103,6 @@ function buildReviewMdIntegrationPrompt(input: { return `You are a repository maintainer editing REVIEW.md, the repository-maintained instructions for automated code review. -Return strict JSON with this shape: -{"status":"updated|already_present","updatedReviewMd":"complete updated REVIEW.md or null","integrationSummary":"short summary"} - Rules: - Preserve existing guidance, ordering, and voice as much as possible. - Integrate the proposal into the most relevant existing section when one exists.