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
5 changes: 5 additions & 0 deletions .changeset/structured-output-provider-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kosong": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the CLI package to this changeset

This changeset lists only the internal @moonshot-ai/kosong package, but the repository's changeset rules require internal package source changes that enter the CLI bundle to manually list @moonshot-ai/kimi-code. As written, the private/internal package can be versioned without bumping the published CLI artifact, so this provider-layer behavior may not ship in the next CLI release/changelog.

Useful? React with 👍 / 👎.

---

Add provider-level structured response format support.
23 changes: 23 additions & 0 deletions packages/kosong/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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}.
*
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
FinishReason,
GenerateOptions,
ProviderRequestAuth,
ResponseFormat,
StreamedMessage,
ThinkingEffort,
} from '#/provider';
Expand Down Expand Up @@ -128,6 +129,27 @@ const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
maxLength: 64,
};

function applyResponseFormat(
kwargs: Record<string, unknown>,
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<string, unknown>) }
: {};
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).
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/kosong/src/providers/google-genai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
FinishReason,
GenerateOptions,
ProviderRequestAuth,
ResponseFormat,
StreamedMessage,
ThinkingEffort,
} from '#/provider';
Expand Down Expand Up @@ -126,6 +127,19 @@ function toolToGoogleGenAI(tool: Tool): GoogleTool {
],
};
}

function applyResponseFormat(
config: Record<string, unknown>,
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove conflicting Gemini schema config

If a Google provider was configured with the native responseSchema in withGenerationKwargs, using the new responseFormat: { type: 'json_schema' } leaves that existing responseSchema in config and adds responseJsonSchema here. Gemini's generateContent config treats those schema fields as mutually exclusive, so that combination turns a previously valid provider config into a rejected request; clear or replace the old schema field when applying the common format.

Useful? React with 👍 / 👎.

}
}
interface GoogleContent {
role: string;
parts: GooglePart[];
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions packages/kosong/src/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
GenerateOptions,
MaxCompletionTokensOptions,
ProviderRequestAuth,
ResponseFormat,
StreamedMessage,
ThinkingEffort,
VideoUploadInput,
Expand Down Expand Up @@ -188,6 +189,21 @@ function convertTool(tool: Tool): OpenAIToolParam {
},
};
}

function responseFormatToOpenAI(format: ResponseFormat): Record<string, unknown> {
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.
Expand Down Expand Up @@ -484,6 +500,9 @@ export class KimiChatProvider implements ChatProvider {
...requestKwargs,
...(extraBody as Record<string, unknown> | undefined),
};
if (options?.responseFormat !== undefined) {
createParams['response_format'] = responseFormatToOpenAI(options.responseFormat);
}

if (tools.length > 0) {
createParams['tools'] = tools.map((t) => convertTool(t));
Expand Down
19 changes: 19 additions & 0 deletions packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
GenerateOptions,
MaxCompletionTokensOptions,
ProviderRequestAuth,
ResponseFormat,
StreamedMessage,
ThinkingEffort,
} from '#/provider';
Expand Down Expand Up @@ -59,6 +60,21 @@ const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
maxLength: 64,
};

function responseFormatToOpenAI(format: ResponseFormat): Record<string, unknown> {
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,
Expand Down Expand Up @@ -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));
Expand Down
20 changes: 20 additions & 0 deletions packages/kosong/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
FinishReason,
GenerateOptions,
ProviderRequestAuth,
ResponseFormat,
StreamedMessage,
ThinkingEffort,
} from '#/provider';
Expand Down Expand Up @@ -362,6 +363,22 @@ interface ResponseToolParam {
parameters: Record<string, unknown>;
strict: boolean;
}

function responseFormatToResponsesText(format: ResponseFormat): Record<string, unknown> {
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.
Expand Down Expand Up @@ -1074,6 +1091,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider {
if (systemPrompt) {
createParams['instructions'] = systemPrompt;
}
if (options?.responseFormat !== undefined) {
createParams['text'] = responseFormatToResponsesText(options.responseFormat);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve existing Responses text options

When a caller has already configured Responses-specific text options via withGenerationKwargs (for example text: { verbosity: 'low' }), this assignment replaces the whole text object after ...kwargs has been applied. Adding responseFormat therefore silently drops those existing options even though text.format can coexist with them; merge the new format into the prior text config instead of overwriting it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve existing Responses text options

When callers have already configured other OpenAI Responses text settings through withGenerationKwargs (for example text: { verbosity: 'low' }), this assignment runs after ...kwargs and replaces the entire text object with only the structured-output format. That silently drops those text settings on every structured-output request; merge the new format into the existing text object instead.

Useful? React with 👍 / 👎.

}

if (
!('responses' in client) ||
Expand Down
48 changes: 47 additions & 1 deletion packages/kosong/test/anthropic.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -62,6 +63,7 @@ async function captureRequestBody(
systemPrompt: string,
tools: Tool[],
history: Message[],
options?: GenerateOptions,
): Promise<Record<string, unknown>> {
let capturedParams: Record<string, unknown> | undefined;
let capturedOptions: Record<string, unknown> | undefined;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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[] = [
Expand Down
Loading
Loading