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:
Gemini Nexus Bot committed 2026-04-22 03:17:46 -03:00
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 {
+32 -4
View File
@@ -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);
+54 -7
View File
@@ -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(