diff --git a/.changeset/structured-output-provider-format.md b/.changeset/structured-output-provider-format.md new file mode 100644 index 000000000..625fb130a --- /dev/null +++ b/.changeset/structured-output-provider-format.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kosong": patch +--- + +Add provider-level structured response format support. diff --git a/packages/kosong/src/provider.ts b/packages/kosong/src/provider.ts index be9bb6212..995e79bf7 100644 --- a/packages/kosong/src/provider.ts +++ b/packages/kosong/src/provider.ts @@ -2,6 +2,24 @@ import type { Message, StreamedMessagePart, VideoURLPart } from './message'; import type { Tool } from './tool'; import type { TokenUsage } from './usage'; +export type JsonSchemaObject = Record; + +export interface JsonObjectResponseFormat { + readonly type: 'json_object'; +} + +export interface JsonSchemaResponseFormat { + readonly type: 'json_schema'; + readonly jsonSchema: { + readonly name: string; + readonly schema: JsonSchemaObject; + readonly strict?: boolean | undefined; + readonly description?: string | undefined; + }; +} + +export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat; + /** * Thinking effort passed to {@link ChatProvider.withThinking}. * @@ -118,6 +136,11 @@ export interface GenerateOptions { * each request/retry so providers never retain mutable credential state. */ auth?: ProviderRequestAuth; + /** + * Optional model-output format constraint. Providers map this to their native + * structured-output field when supported. + */ + responseFormat?: ResponseFormat; /** * Host-side instrumentation hook fired immediately before invoking the * provider adapter's generate call. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 9d38d9e3f..43e653989 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -12,6 +12,7 @@ import type { FinishReason, GenerateOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -142,6 +143,27 @@ const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { maxLength: 64, }; +function applyResponseFormat( + kwargs: Record, + format: ResponseFormat | undefined, +): void { + if (format === undefined) return; + if (format.type === 'json_object') { + throw new ChatProviderError( + 'Anthropic provider requires a JSON schema for structured response output.', + ); + } + const outputConfig = + kwargs['output_config'] !== undefined && kwargs['output_config'] !== null + ? { ...(kwargs['output_config'] as Record) } + : {}; + outputConfig['format'] = { + type: 'json_schema', + schema: format.jsonSchema.schema, + }; + kwargs['output_config'] = outputConfig; +} + /** * Per-version default output ceilings sourced from Anthropic's Messages * API model cards (platform.claude.com/docs/en/about-claude/models/overview). @@ -1114,6 +1136,7 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.output_config !== undefined) { kwargs['output_config'] = this._generationKwargs.output_config; } + applyResponseFormat(kwargs, options?.responseFormat); if (this._generationKwargs.contextManagement !== undefined) { kwargs['context_management'] = this._generationKwargs.contextManagement; } diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 0a72b00ea..e39d27d50 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -11,6 +11,7 @@ import type { FinishReason, GenerateOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -127,6 +128,19 @@ function toolToGoogleGenAI(tool: Tool): GoogleTool { ], }; } + +function applyResponseFormat( + config: Record, + format: ResponseFormat | undefined, +): void { + if (format === undefined) return; + config['responseMimeType'] = 'application/json'; + delete config['responseSchema']; + delete config['responseJsonSchema']; + if (format.type === 'json_schema') { + config['responseJsonSchema'] = format.jsonSchema.schema; + } +} interface GoogleContent { role: string; parts: GooglePart[]; @@ -819,6 +833,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { systemInstruction: systemPrompt, ...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}), }; + applyResponseFormat(config, options?.responseFormat); try { const client = this._createClient(options?.auth); diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 30c2a3f99..44f63268c 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -6,6 +6,7 @@ import type { GenerateOptions, MaxCompletionTokensOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, VideoUploadInput, @@ -200,6 +201,21 @@ function convertTool(tool: Tool): OpenAIToolParam { }, }; } + +function responseFormatToOpenAI(format: ResponseFormat): Record { + if (format.type === 'json_object') { + return { type: 'json_object' }; + } + return { + type: 'json_schema', + json_schema: { + name: format.jsonSchema.name, + schema: format.jsonSchema.schema, + strict: format.jsonSchema.strict, + description: format.jsonSchema.description, + }, + }; +} /** * Extract usage from a streaming chunk. Moonshot may place usage in * `choices[0].usage` in addition to the top-level `usage` field. @@ -505,6 +521,9 @@ export class KimiChatProvider implements ChatProvider { ...requestKwargs, ...(extraBody as Record | undefined), }; + if (options?.responseFormat !== undefined) { + createParams['response_format'] = responseFormatToOpenAI(options.responseFormat); + } if (tools.length > 0) { createParams['tools'] = tools.map((t) => convertTool(t)); diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index c45d8f95c..35d759051 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -6,6 +6,7 @@ import type { GenerateOptions, MaxCompletionTokensOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -60,6 +61,21 @@ const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { maxLength: 64, }; +function responseFormatToOpenAI(format: ResponseFormat): Record { + if (format.type === 'json_object') { + return { type: 'json_object' }; + } + return { + type: 'json_schema', + json_schema: { + name: format.jsonSchema.name, + schema: format.jsonSchema.schema, + strict: format.jsonSchema.strict, + description: format.jsonSchema.description, + }, + }; +} + function extractReasoningContent( source: unknown, explicitKey: string | undefined, @@ -571,6 +587,9 @@ export class OpenAILegacyChatProvider implements ChatProvider { stream: this._stream, ...kwargs, }; + if (options?.responseFormat !== undefined) { + createParams['response_format'] = responseFormatToOpenAI(options.responseFormat); + } if (tools.length > 0) { createParams['tools'] = tools.map((t) => toolToOpenAI(t)); diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 5549471d6..389d28d72 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -11,6 +11,7 @@ import type { FinishReason, GenerateOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -362,6 +363,22 @@ interface ResponseToolParam { parameters: Record; strict: boolean; } + +function responseFormatToResponsesText(format: ResponseFormat): Record { + if (format.type === 'json_object') { + return { format: { type: 'json_object' } }; + } + return { + format: { + type: 'json_schema', + name: format.jsonSchema.name, + schema: format.jsonSchema.schema, + strict: format.jsonSchema.strict, + description: format.jsonSchema.description, + }, + }; +} + // The Responses API has no input type for video, and only mp3/wav audio can // be inlined as input_file data. Degrade such parts to placeholder text so // the model still learns an attachment existed instead of silently losing it. @@ -1083,6 +1100,12 @@ export class OpenAIResponsesChatProvider implements ChatProvider { if (systemPrompt) { createParams['instructions'] = systemPrompt; } + if (options?.responseFormat !== undefined) { + createParams['text'] = { + ...asRawObject(createParams['text']), + ...responseFormatToResponsesText(options.responseFormat), + }; + } if ( !('responses' in client) || diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 3a3940495..ca9005439 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1,6 +1,7 @@ import { ChatProviderError } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { AnthropicChatProvider, resolveDefaultMaxTokens } from '#/providers/anthropic'; +import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -63,6 +64,7 @@ async function captureRequestBody( systemPrompt: string, tools: Tool[], history: Message[], + options?: GenerateOptions, ): Promise> { let capturedParams: Record | undefined; let capturedOptions: Record | undefined; @@ -75,7 +77,7 @@ async function captureRequestBody( return Promise.resolve(makeAnthropicResponse()); }); - const stream = await provider.generate(systemPrompt, tools, history); + const stream = await provider.generate(systemPrompt, tools, history, options); for await (const part of stream) { void part; } @@ -405,6 +407,50 @@ describe('AnthropicChatProvider', () => { ]); }); + it('maps json_schema response format to output_config.format', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + expect(body['output_config']).toEqual({ + format: { + type: 'json_schema', + schema, + }, + }); + }); + + it('rejects json_object response format because Anthropic requires a schema', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + + await expect( + provider.generate('', [], history, { + responseFormat: { type: 'json_object' }, + }), + ).rejects.toThrow('Anthropic provider requires a JSON schema for structured response output.'); + }); + it('multi-turn conversation', async () => { const provider = createProvider(); const history: Message[] = [ diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index 772eebc0c..7f1fa81de 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -13,6 +13,7 @@ import { GoogleGenAIStreamedMessage, messagesToGoogleGenAIContents, } from '#/providers/google-genai'; +import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -50,6 +51,7 @@ async function captureRequestBody( systemPrompt: string, tools: Tool[], history: Message[], + options?: GenerateOptions, ): Promise> { let capturedBody: Record | undefined; @@ -69,7 +71,7 @@ async function captureRequestBody( return Promise.resolve(makeGenerateContentResponse()); }); - const stream = await provider.generate(systemPrompt, tools, history); + const stream = await provider.generate(systemPrompt, tools, history, options); for await (const part of stream) { void part; } @@ -131,6 +133,66 @@ describe('GoogleGenAIChatProvider', () => { expect(config['systemInstruction']).toBe('You are helpful.'); }); + it('maps json_schema response format to response config', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + const config = body['config'] as Record; + expect(config['responseMimeType']).toBe('application/json'); + expect(config['responseJsonSchema']).toEqual(schema); + }); + + it('replaces native responseSchema when applying json_schema response format', async () => { + const provider = createProvider().withGenerationKwargs({ + responseSchema: { + type: 'object', + properties: { old: { type: 'string' } }, + }, + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + const config = body['config'] as Record; + expect(config['responseSchema']).toBeUndefined(); + expect(config['responseJsonSchema']).toEqual(schema); + }); + it('system messages in history are wrapped and emitted as user content', () => { // Regression: Google GenAI's Content.role only accepts "user" or // "model", so a `system` message sitting in the replay history (from diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 2ac66830e..ff9577754 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -2,6 +2,7 @@ import { generate } from '#/generate'; import type { ContentPart, Message, ToolCall } from '#/message'; import { extractUsageFromChunk, KimiChatProvider } from '#/providers/kimi'; import { extractUsage } from '#/providers/openai-common'; +import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -51,6 +52,7 @@ async function captureRequestBody( systemPrompt: string, tools: Tool[], history: Message[], + options?: GenerateOptions, ): Promise> { let capturedBody: Record | undefined; @@ -61,7 +63,7 @@ async function captureRequestBody( return Promise.resolve(makeChatCompletionResponse('kimi-k2')); }); - const stream = await provider.generate(systemPrompt, tools, history); + const stream = await provider.generate(systemPrompt, tools, history, options); for await (const part of stream) { void part; } @@ -619,6 +621,39 @@ describe('KimiChatProvider', () => { expect(body['max_tokens']).toBeUndefined(); }); + it('maps json_schema response format to response_format', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + expect(body['response_format']).toEqual({ + type: 'json_schema', + json_schema: { + name: 'contact', + schema, + strict: true, + description: undefined, + }, + }); + }); + it('prefers max_completion_tokens when both fields are set', async () => { const provider = createProvider().withGenerationKwargs({ max_completion_tokens: 2048, diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index c77607f3d..1356261b3 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1,6 +1,7 @@ import { generate } from '#/generate'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { OpenAILegacyChatProvider } from '#/providers/openai-legacy'; +import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -42,6 +43,7 @@ async function captureRequestBody( systemPrompt: string, tools: Tool[], history: Message[], + options?: GenerateOptions, ): Promise> { let capturedBody: Record | undefined; @@ -52,7 +54,7 @@ async function captureRequestBody( return Promise.resolve(makeChatCompletionResponse()); }); - const stream = await provider.generate(systemPrompt, tools, history); + const stream = await provider.generate(systemPrompt, tools, history, options); for await (const part of stream) { void part; } @@ -603,6 +605,39 @@ describe('OpenAILegacyChatProvider', () => { expect(body['max_tokens']).toBe(2048); }); + it('maps json_schema response format to response_format', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + expect(body['response_format']).toEqual({ + type: 'json_schema', + json_schema: { + name: 'contact', + schema, + strict: true, + description: undefined, + }, + }); + }); + it('withMaxCompletionTokens sets max_tokens on the cloned provider', async () => { const original = createProvider(); const provider = original.withMaxCompletionTokens(1024); diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 0ea272b7d..7421c8fdf 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -10,6 +10,7 @@ import { OpenAIResponsesChatProvider, OpenAIResponsesStreamedMessage, } from '#/providers/openai-responses'; +import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -45,6 +46,7 @@ async function captureRequestBody( systemPrompt: string, tools: Tool[], history: Message[], + options?: GenerateOptions, ): Promise> { let capturedBody: Record | undefined; @@ -57,7 +59,7 @@ async function captureRequestBody( return Promise.resolve(makeResponsesAPIResponse()); }); - const stream = await provider.generate(systemPrompt, tools, history); + const stream = await provider.generate(systemPrompt, tools, history, options); for await (const part of stream) { void part; } @@ -910,6 +912,75 @@ describe('OpenAIResponsesChatProvider', () => { expect(body['max_output_tokens']).toBe(1024); expect(provider.maxCompletionTokens).toBe(1024); }); + + it('maps json_schema response format to text.format', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + expect(body['text']).toEqual({ + format: { + type: 'json_schema', + name: 'contact', + schema, + strict: true, + description: undefined, + }, + }); + }); + + it('preserves existing Responses text options when applying response format', async () => { + const provider = createProvider().withGenerationKwargs({ + text: { verbosity: 'low' }, + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] }, + ]; + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }; + const body = await captureRequestBody(provider, '', [], history, { + responseFormat: { + type: 'json_schema', + jsonSchema: { + name: 'contact', + schema, + strict: true, + }, + }, + }); + + expect(body['text']).toEqual({ + verbosity: 'low', + format: { + type: 'json_schema', + name: 'contact', + schema, + strict: true, + description: undefined, + }, + }); + }); }); describe('reasoning configuration', () => {