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 b2f82ed80..466995940 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 a031b75ee..8baa2fdaa 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -11,6 +11,7 @@ import type { FinishReason, GenerateOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -128,6 +129,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). @@ -1076,6 +1098,7 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.output_config !== undefined) { kwargs['output_config'] = this._generationKwargs.output_config; } + applyResponseFormat(kwargs, options?.responseFormat); // Build the beta feature list. On the standard Messages API these travel // via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 1b04c928c..d606e4df8 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -10,6 +10,7 @@ import type { FinishReason, GenerateOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -126,6 +127,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[]; @@ -804,6 +818,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 e3a5a05e9..3f6b9a056 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, @@ -188,6 +189,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. @@ -484,6 +500,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 50187fd7a..6cf0b8631 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -5,6 +5,7 @@ import type { GenerateOptions, MaxCompletionTokensOptions, ProviderRequestAuth, + ResponseFormat, StreamedMessage, ThinkingEffort, } from '#/provider'; @@ -59,6 +60,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, @@ -556,6 +572,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 48544b81b..6e2912a1c 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. @@ -1074,6 +1091,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { if (systemPrompt) { createParams['instructions'] = systemPrompt; } + if (options?.responseFormat !== undefined) { + 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 470379b83..fe08d4e09 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'; @@ -62,6 +63,7 @@ async function captureRequestBody( systemPrompt: string, tools: Tool[], history: Message[], + options?: GenerateOptions, ): Promise> { let capturedParams: Record | undefined; let capturedOptions: Record | undefined; @@ -74,7 +76,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; } @@ -281,6 +283,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 3bcf00030..35d002d46 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 17bee33d4..c5926b9ea 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 f02efd048..38b65075d 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 cc7073a48..b70d6eef0 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; } @@ -909,6 +911,39 @@ describe('OpenAIResponsesChatProvider', () => { expect(provider).not.toBe(original); expect(body['max_output_tokens']).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, + }, + }); + }); }); describe('reasoning configuration', () => {