mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-31 10:37:12 +00:00
feat(agent-core-v2): support openai_responses protocol for kimi provider (#3375)
This commit is contained in:
parent
9e881528a8
commit
8f2c60b328
5 changed files with 346 additions and 5 deletions
|
|
@ -227,6 +227,12 @@ const kimiEndpoint: ProtocolEndpoint = {
|
|||
defaultBaseUrl: KIMI_DEFAULT_BASE_URL,
|
||||
};
|
||||
|
||||
export const kimiResponsesTrait: ProtocolTrait = {
|
||||
endpoint: () => kimiEndpoint,
|
||||
|
||||
convertError: (error) => classifyKimiQuotaError(error),
|
||||
};
|
||||
|
||||
registerProviderDefinition({
|
||||
id: 'kimi',
|
||||
baseProtocol: 'openai',
|
||||
|
|
@ -244,3 +250,12 @@ registerProviderDefinition({
|
|||
hostHeaders: 'full',
|
||||
modelSource: 'oauth-catalog',
|
||||
});
|
||||
|
||||
registerProviderDefinition({
|
||||
id: 'kimi',
|
||||
baseProtocol: 'openai_responses',
|
||||
traits: [kimiResponsesTrait],
|
||||
endpoint: kimiEndpoint,
|
||||
hostHeaders: 'full',
|
||||
modelSource: 'oauth-catalog',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
ResponseFormat,
|
||||
StreamedMessage,
|
||||
} from '#/kosong/contract/provider';
|
||||
import type { Tool } from '#/kosong/contract/tool';
|
||||
import '#/kosong/provider/bases/anthropic/index';
|
||||
import {
|
||||
AnthropicChatProvider,
|
||||
|
|
@ -212,6 +213,12 @@ describe('resolveAdapterIdentity', () => {
|
|||
expect(identity.traits).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolves the (kimi, openai_responses) pair registration: its traits plus the trailing synthetic trait', () => {
|
||||
const identity = registry.resolveAdapterIdentity('openai_responses', 'kimi');
|
||||
expect(identity.baseId).toBe('openai_responses');
|
||||
expect(identity.traits).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolves an unregistered (vendor, protocol) pair to no vendor traits', () => {
|
||||
const identity = registry.resolveAdapterIdentity('google-genai', 'kimi');
|
||||
expect(identity.baseId).toBe('google-genai');
|
||||
|
|
@ -412,11 +419,14 @@ describe('kimi provider definitions', () => {
|
|||
it('registers one definition per transport, with shared vendor-level facts', () => {
|
||||
const native = getProviderDefinition('kimi', 'openai');
|
||||
const anthropic = getProviderDefinition('kimi', 'anthropic');
|
||||
const responses = getProviderDefinition('kimi', 'openai_responses');
|
||||
expect(native?.baseProtocol).toBe('openai');
|
||||
expect(native?.traits).toHaveLength(1);
|
||||
expect(anthropic?.baseProtocol).toBe('anthropic');
|
||||
expect(anthropic?.traits).toHaveLength(1);
|
||||
for (const definition of [native, anthropic]) {
|
||||
expect(responses?.baseProtocol).toBe('openai_responses');
|
||||
expect(responses?.traits).toHaveLength(1);
|
||||
for (const definition of [native, anthropic, responses]) {
|
||||
expect(definition?.endpoint).toEqual({
|
||||
apiKeyEnv: 'KIMI_API_KEY',
|
||||
baseUrlEnv: 'KIMI_BASE_URL',
|
||||
|
|
@ -429,7 +439,7 @@ describe('kimi provider definitions', () => {
|
|||
|
||||
it('answers id-level queries and reports unregistered pairs', () => {
|
||||
expect(getProviderDefinition('kimi')?.baseProtocol).toBe('openai');
|
||||
expect(getProviderDefinitions('kimi')).toHaveLength(2);
|
||||
expect(getProviderDefinitions('kimi')).toHaveLength(3);
|
||||
expect(hasProviderDefinition('kimi')).toBe(true);
|
||||
expect(hasProviderDefinition('no-such-vendor')).toBe(false);
|
||||
expect(getProviderDefinition('kimi', 'google-genai')).toBeUndefined();
|
||||
|
|
@ -1392,3 +1402,283 @@ describe('429 wire behavior over real HTTP (no hidden SDK retry)', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const ADD_TOOL: Tool = {
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { a: { type: 'number' }, b: { type: 'number' } },
|
||||
required: ['a', 'b'],
|
||||
},
|
||||
};
|
||||
|
||||
const RESPONSES_HISTORY: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'think', think: 'I should add them.' },
|
||||
{ type: 'text', text: 'On it.' },
|
||||
],
|
||||
toolCalls: [{ type: 'function', id: 'call_add1', name: 'add', arguments: '{"a":2,"b":3}' }],
|
||||
},
|
||||
{ role: 'tool', content: [{ type: 'text', text: '5' }], toolCallId: 'call_add1', toolCalls: [] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'And double it?' }], toolCalls: [] },
|
||||
];
|
||||
|
||||
describe('kimi over the Responses wire (real HTTP)', () => {
|
||||
interface CapturedRequest {
|
||||
readonly url: string;
|
||||
readonly body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ResponsesServerOutcome =
|
||||
| { readonly events: readonly unknown[] }
|
||||
| { readonly status: number; readonly body: Record<string, unknown> };
|
||||
|
||||
async function withKimiResponsesServer(
|
||||
respond: (body: Record<string, unknown>) => ResponsesServerOutcome,
|
||||
run: (port: number, requests: () => readonly CapturedRequest[]) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
|
||||
requests.push({ url: req.url ?? '', body });
|
||||
const outcome = respond(body);
|
||||
if ('events' in outcome) {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
for (const event of outcome.events) {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(outcome.status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(outcome.body));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('server has no address');
|
||||
}
|
||||
await run(address.port, () => requests);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function kimiResponsesProvider(port: number): ChatProvider {
|
||||
return registry.createChatProvider({
|
||||
protocol: 'openai_responses',
|
||||
providerType: 'kimi',
|
||||
modelName: 'kimi-k2',
|
||||
apiKey: 'sk-probe',
|
||||
baseUrl: `http://127.0.0.1:${String(port)}/v1`,
|
||||
});
|
||||
}
|
||||
|
||||
it('runs a multi-turn generate with thinking and tool-call history over /v1/responses', async () => {
|
||||
await withKimiResponsesServer(
|
||||
() => ({
|
||||
events: [
|
||||
{ type: 'response.created', response: { id: 'resp_kimi_1' } },
|
||||
{ type: 'response.output_text.delta', delta: 'The sum is 5.' },
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_kimi_1',
|
||||
status: 'completed',
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 3,
|
||||
total_tokens: 13,
|
||||
input_tokens_details: { cached_tokens: 4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
async (port, requests) => {
|
||||
const provider = kimiResponsesProvider(port);
|
||||
const stream = await provider.generate('sys', [], RESPONSES_HISTORY);
|
||||
const parts: unknown[] = [];
|
||||
for await (const part of stream) {
|
||||
parts.push(part);
|
||||
}
|
||||
expect(parts).toEqual([{ type: 'text', text: 'The sum is 5.' }]);
|
||||
expect(stream.usage).toEqual({
|
||||
inputOther: 6,
|
||||
output: 3,
|
||||
inputCacheRead: 4,
|
||||
inputCacheCreation: 0,
|
||||
});
|
||||
expect(stream.finishReason).toBe('completed');
|
||||
|
||||
const [request] = requests();
|
||||
if (request === undefined) throw new Error('expected one request');
|
||||
expect(request.url).toBe('/v1/responses');
|
||||
expect(request.body['model']).toBe('kimi-k2');
|
||||
expect(request.body['instructions']).toBe('sys');
|
||||
expect(request.body['store']).toBe(false);
|
||||
expect(request.body['stream']).toBe(true);
|
||||
expect(request.body['input']).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Add 2 and 3' }],
|
||||
},
|
||||
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'I should add them.' }] },
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'On it.', annotations: [] }],
|
||||
},
|
||||
{ type: 'function_call', call_id: 'call_add1', name: 'add', arguments: '{"a":2,"b":3}' },
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_add1',
|
||||
output: [{ type: 'input_text', text: '5' }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'And double it?' }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes thinking as standard reasoning on the Responses wire', async () => {
|
||||
await withKimiResponsesServer(
|
||||
() => ({
|
||||
events: [
|
||||
{ type: 'response.created', response: { id: 'resp_kimi_2' } },
|
||||
{ type: 'response.reasoning_summary_text.delta', delta: 'Hmm' },
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_kimi_2',
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 3, output_tokens: 1, total_tokens: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
async (port, requests) => {
|
||||
const provider = kimiResponsesProvider(port);
|
||||
const stream = await provider.generate('', [], PROBE_HISTORY, {
|
||||
thinking: { effort: 'high' },
|
||||
});
|
||||
const parts: unknown[] = [];
|
||||
for await (const part of stream) {
|
||||
parts.push(part);
|
||||
}
|
||||
expect(parts).toEqual([{ type: 'think', think: 'Hmm' }]);
|
||||
|
||||
const [request] = requests();
|
||||
if (request === undefined) throw new Error('expected one request');
|
||||
expect(request.body['reasoning']).toEqual({ effort: 'high', summary: 'auto' });
|
||||
expect(request.body['include']).toEqual(['reasoning.encrypted_content']);
|
||||
expect(request.body).not.toHaveProperty('thinking');
|
||||
expect(request.body).not.toHaveProperty('extra_body');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a standard function tool call from the Responses stream', async () => {
|
||||
await withKimiResponsesServer(
|
||||
() => ({
|
||||
events: [
|
||||
{ type: 'response.created', response: { id: 'resp_kimi_3' } },
|
||||
{
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: 'function_call',
|
||||
id: 'fc_1',
|
||||
call_id: 'call_1',
|
||||
name: 'add',
|
||||
arguments: '{"a":2,"b":3}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_kimi_3',
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 5, output_tokens: 4, total_tokens: 9 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
async (port, requests) => {
|
||||
const provider = kimiResponsesProvider(port);
|
||||
const stream = await provider.generate('', [ADD_TOOL], PROBE_HISTORY);
|
||||
const parts: unknown[] = [];
|
||||
for await (const part of stream) {
|
||||
parts.push(part);
|
||||
}
|
||||
expect(parts).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
id: 'call_1',
|
||||
name: 'add',
|
||||
arguments: '{"a":2,"b":3}',
|
||||
_streamIndex: 'fc_1',
|
||||
},
|
||||
]);
|
||||
|
||||
const [request] = requests();
|
||||
if (request === undefined) throw new Error('expected one request');
|
||||
expect(request.body['tools']).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
parameters: ADD_TOOL.parameters,
|
||||
strict: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a Moonshot quota 429 as quota-exhausted over the Responses wire', async () => {
|
||||
await withKimiResponsesServer(
|
||||
() => ({
|
||||
status: 429,
|
||||
body: {
|
||||
error: {
|
||||
type: 'exceeded_current_quota_error',
|
||||
message:
|
||||
'Your account is suspended due to insufficient balance, please recharge your account',
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (port, requests) => {
|
||||
const provider = kimiResponsesProvider(port);
|
||||
const caught = await provider.generate('sys', [], PROBE_HISTORY).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError);
|
||||
expect(isRetryableGenerateError(caught)).toBe(false);
|
||||
expect(requests()).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const ModelAliasBaseSchema = z.object({
|
|||
capabilities: z.array(z.string()).optional(),
|
||||
displayName: z.string().optional(),
|
||||
reasoningKey: z.string().optional(),
|
||||
protocol: z.literal('anthropic').optional(),
|
||||
protocol: z.enum(['anthropic', 'openai_responses']).optional(),
|
||||
// Explicitly declare adaptive-thinking support, overriding the kosong
|
||||
// model-name version inference. Needed for custom-named Anthropic endpoints
|
||||
// whose model name does not encode a parseable Claude version.
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ export const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code';
|
|||
export const KIMI_CODE_OAUTH_KEY = 'oauth/kimi-code';
|
||||
const KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/kimi-code-env-';
|
||||
|
||||
export type ManagedKimiCodeProtocol = 'kimi' | 'anthropic';
|
||||
export type ManagedKimiCodeProtocol = 'kimi' | 'anthropic' | 'openai_responses';
|
||||
|
||||
export function parseModelProtocol(value: unknown): ManagedKimiCodeProtocol | undefined {
|
||||
return value === 'anthropic' ? 'anthropic' : undefined;
|
||||
if (value === 'anthropic') return 'anthropic';
|
||||
if (value === 'response') return 'openai_responses';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1423,6 +1423,40 @@ describe('managed protocol routing', () => {
|
|||
expect(models[0]?.protocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('maps the server "response" protocol value to openai_responses', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [{ id: 'k3', context_length: 1048576, protocol: 'response' }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const models = await fetchManagedKimiCodeModels({ accessToken: 't', fetchImpl });
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.protocol).toBe('openai_responses');
|
||||
});
|
||||
|
||||
it('records openai_responses protocol without anthropic routing fields', () => {
|
||||
const config: ManagedKimiConfigShape = { providers: {} };
|
||||
applyManagedKimiCodeConfig(config, {
|
||||
baseUrl: KIMI_BASE_URL,
|
||||
models: [makeModelInfo('k3', { protocol: 'openai_responses', supportsReasoning: true })],
|
||||
});
|
||||
|
||||
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
||||
type: 'kimi',
|
||||
baseUrl: KIMI_BASE_URL,
|
||||
apiKey: '',
|
||||
});
|
||||
const alias = config.models?.['kimi-code/k3'];
|
||||
expect(alias?.protocol).toBe('openai_responses');
|
||||
expect(alias?.betaApi).toBeUndefined();
|
||||
expect(alias?.adaptiveThinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the provider on the kimi REST base and records the model protocol when anthropic', () => {
|
||||
const config: ManagedKimiConfigShape = { providers: {} };
|
||||
applyManagedKimiCodeConfig(config, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue