fix(api): full OpenAI-spec compliance for OpenCode integration
Route fixes: - Add POST /v1/messages/chat/completions alias so OpenCode clients misconfigured with baseURL ending in /v1/messages no longer get 404 Session affinity: - Extract x-session-affinity HTTP header (sent by OpenCode) and use it as the session key for token stickiness, falling back to body-level session_id fields if the header is absent Tool calling (streaming): - finish_reason is now 'tool_calls' when function calls were emitted in the stream (Gemini always sends STOP, so we check toolCallIndex > 0) - createSyntheticOpenAIStream (stream fallback path) now emits all tool_call deltas before the finish chunk instead of silently dropping them when content is null Tool calling (non-streaming): - content is now always null when tool_calls are present, satisfying strict clients that reject non-null content alongside tool_calls - system_fingerprint field added to OpenAIChatResponse interface and response builder for OpenAI spec compliance Usage accounting: - thoughtsTokenCount is now included in completion_tokens and total_tokens for the streaming usage chunk, fixing undercount for thinking/reasoning model runs
This commit is contained in:
1 parent
0f9d08018b
commit
a3eabc2aaa
3 files changed
+88
-11
No files matched your search
@@ -184,6 +184,8 @@ export interface OpenAIChatResponse {
|
|||||||
completion_tokens: number;
|
completion_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
};
|
};
|
||||||
|
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
|
||||||
|
system_fingerprint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIChoice {
|
export interface OpenAIChoice {
|
||||||
|
|||||||
@@ -84,8 +84,26 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('chat/completions')
|
@Post('chat/completions')
|
||||||
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
async chatCompletions(
|
||||||
await this.respondOpenAIChatCompletions(body, res);
|
@Body() body: OpenAIChatRequest,
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
await this.respondOpenAIChatCompletions(body, res, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias route: some clients (e.g. OpenCode configured with baseURL ending in /v1/messages)
|
||||||
|
* send requests to /v1/messages/chat/completions. Forward to the same handler so those
|
||||||
|
* clients do not receive a 404.
|
||||||
|
*/
|
||||||
|
@Post('messages/chat/completions')
|
||||||
|
async messagesCompletionsAlias(
|
||||||
|
@Body() body: OpenAIChatRequest,
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
await this.respondOpenAIChatCompletions(body, res, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('completions')
|
@Post('completions')
|
||||||
@@ -303,13 +321,23 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
|
private async respondOpenAIChatCompletions(
|
||||||
|
body: OpenAIChatRequest,
|
||||||
|
res: FastifyReply,
|
||||||
|
req?: FastifyRequest,
|
||||||
|
) {
|
||||||
const requestId = uuidv4();
|
const requestId = uuidv4();
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream });
|
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream });
|
||||||
|
|
||||||
|
// Extract x-session-affinity header so OpenCode session stickiness works even when
|
||||||
|
// the request body does not carry an explicit session_id field.
|
||||||
|
const headerSessionKey = req
|
||||||
|
? (req.headers['x-session-affinity'] as string | undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.proxyService.handleChatCompletions(body, requestId);
|
const result = await this.proxyService.handleChatCompletions(body, requestId, headerSessionKey);
|
||||||
|
|
||||||
if (body.stream && this.isObservableLike(result)) {
|
if (body.stream && this.isObservableLike(result)) {
|
||||||
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
|
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
|
||||||
|
|||||||
@@ -691,8 +691,13 @@ export class ProxyService {
|
|||||||
|
|
||||||
async handleChatCompletions(
|
async handleChatCompletions(
|
||||||
request: OpenAIChatRequest,
|
request: OpenAIChatRequest,
|
||||||
|
_requestId?: string,
|
||||||
|
headerSessionKey?: string,
|
||||||
): Promise<OpenAIChatResponse | Observable<string>> {
|
): Promise<OpenAIChatResponse | Observable<string>> {
|
||||||
const sessionKey = this.extractOpenAISessionKey(request);
|
// Prefer the x-session-affinity header value (sent by OpenCode) over body-level session ids.
|
||||||
|
const sessionKey =
|
||||||
|
(headerSessionKey ? `openai:${headerSessionKey}` : undefined) ??
|
||||||
|
this.extractOpenAISessionKey(request);
|
||||||
|
|
||||||
const targetModel = this.resolveTargetModel(request.model);
|
const targetModel = this.resolveTargetModel(request.model);
|
||||||
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||||
@@ -1126,6 +1131,13 @@ export class ProxyService {
|
|||||||
pushChunk(closingChunk);
|
pushChunk(closingChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per the OpenAI spec, finish_reason must be "tool_calls" when the
|
||||||
|
// response contains function calls, even though Gemini always sends "STOP".
|
||||||
|
const mappedFinishReason =
|
||||||
|
toolCallIndex > 0
|
||||||
|
? 'tool_calls'
|
||||||
|
: this.mapGeminiFinishReasonToOpenAIFinishReason(candidate.finishReason);
|
||||||
|
|
||||||
const finishChunk = {
|
const finishChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1135,9 +1147,7 @@ export class ProxyService {
|
|||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {},
|
delta: {},
|
||||||
finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason(
|
finish_reason: mappedFinishReason,
|
||||||
candidate.finishReason,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1177,10 +1187,13 @@ export class ProxyService {
|
|||||||
choices: [],
|
choices: [],
|
||||||
usage: {
|
usage: {
|
||||||
prompt_tokens: lastUsageMetadata.promptTokenCount || 0,
|
prompt_tokens: lastUsageMetadata.promptTokenCount || 0,
|
||||||
completion_tokens: lastUsageMetadata.candidatesTokenCount || 0,
|
completion_tokens:
|
||||||
|
(lastUsageMetadata.candidatesTokenCount || 0) +
|
||||||
|
(lastUsageMetadata.thoughtsTokenCount || 0),
|
||||||
total_tokens:
|
total_tokens:
|
||||||
(lastUsageMetadata.promptTokenCount || 0) +
|
(lastUsageMetadata.promptTokenCount || 0) +
|
||||||
(lastUsageMetadata.candidatesTokenCount || 0),
|
(lastUsageMetadata.candidatesTokenCount || 0) +
|
||||||
|
(lastUsageMetadata.thoughtsTokenCount || 0),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
pushChunk(usageChunk);
|
pushChunk(usageChunk);
|
||||||
@@ -1213,10 +1226,42 @@ export class ProxyService {
|
|||||||
const model = response.model;
|
const model = response.model;
|
||||||
const choice = response.choices?.[0];
|
const choice = response.choices?.[0];
|
||||||
const finishReason = choice?.finish_reason ?? 'stop';
|
const finishReason = choice?.finish_reason ?? 'stop';
|
||||||
|
const toolCalls = choice?.message?.tool_calls;
|
||||||
const content =
|
const content =
|
||||||
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
||||||
const chunkSize = 80;
|
const chunkSize = 80;
|
||||||
|
|
||||||
|
// Emit tool_call deltas first when the fallback non-streaming response contains tool calls.
|
||||||
|
// Without this, tool calls are silently dropped when the primary stream path fails.
|
||||||
|
if (toolCalls && toolCalls.length > 0) {
|
||||||
|
for (let tcIndex = 0; tcIndex < toolCalls.length; tcIndex++) {
|
||||||
|
const tc = toolCalls[tcIndex];
|
||||||
|
const tcChunk = {
|
||||||
|
id: streamId,
|
||||||
|
object: 'chat.completion.chunk',
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: tcIndex,
|
||||||
|
id: tc.id,
|
||||||
|
type: tc.type,
|
||||||
|
function: tc.function,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
subscriber.next(`data: ${JSON.stringify(tcChunk)}\n\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (content.length === 0) {
|
if (content.length === 0) {
|
||||||
const finishChunk = {
|
const finishChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
@@ -1623,12 +1668,14 @@ export class ProxyService {
|
|||||||
object: 'chat.completion',
|
object: 'chat.completion',
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
model: model,
|
model: model,
|
||||||
|
system_fingerprint: 'fp_gemininexus',
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: finalContent ? finalContent : toolCalls.length > 0 ? null : '',
|
// When tool_calls are present, content must be null per OpenAI spec.
|
||||||
|
content: toolCalls.length > 0 ? null : (finalContent || ''),
|
||||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||||
},
|
},
|
||||||
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
||||||
|
|||||||
Reference in new issue
Block a user