From a3eabc2aaa38fae8d6c7e74bb82cc1f2052dcf74 Mon Sep 17 00:00:00 2001 From: Gemini Nexus Bot Date: Wed, 22 Apr 2026 03:17:46 -0300 Subject: [PATCH] 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 --- .../proxy/interfaces/request-interfaces.ts | 2 + src/server/modules/proxy/proxy.controller.ts | 36 +++++++++-- src/server/modules/proxy/proxy.service.ts | 61 ++++++++++++++++--- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/server/modules/proxy/interfaces/request-interfaces.ts b/src/server/modules/proxy/interfaces/request-interfaces.ts index 81a63b4..76873ef 100644 --- a/src/server/modules/proxy/interfaces/request-interfaces.ts +++ b/src/server/modules/proxy/interfaces/request-interfaces.ts @@ -184,6 +184,8 @@ export interface OpenAIChatResponse { completion_tokens: number; total_tokens: number; }; + /** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */ + system_fingerprint?: string; } export interface OpenAIChoice { diff --git a/src/server/modules/proxy/proxy.controller.ts b/src/server/modules/proxy/proxy.controller.ts index c8c3d29..dc710d6 100644 --- a/src/server/modules/proxy/proxy.controller.ts +++ b/src/server/modules/proxy/proxy.controller.ts @@ -84,8 +84,26 @@ export class ProxyController { } @Post('chat/completions') - async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) { - await this.respondOpenAIChatCompletions(body, res); + async chatCompletions( + @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') @@ -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 startTime = Date.now(); 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 { - const result = await this.proxyService.handleChatCompletions(body, requestId); + const result = await this.proxyService.handleChatCompletions(body, requestId, headerSessionKey); if (body.stream && this.isObservableLike(result)) { this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime); diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index 38c3efe..1db980d 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -691,8 +691,13 @@ export class ProxyService { async handleChatCompletions( request: OpenAIChatRequest, + _requestId?: string, + headerSessionKey?: string, ): Promise> { - 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 extraHeaders = this.createModelSpecificHeaders(request.model); @@ -1126,6 +1131,13 @@ export class ProxyService { 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 = { id: streamId, object: 'chat.completion.chunk', @@ -1135,9 +1147,7 @@ export class ProxyService { { index: 0, delta: {}, - finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason( - candidate.finishReason, - ), + finish_reason: mappedFinishReason, }, ], }; @@ -1177,10 +1187,13 @@ export class ProxyService { choices: [], usage: { prompt_tokens: lastUsageMetadata.promptTokenCount || 0, - completion_tokens: lastUsageMetadata.candidatesTokenCount || 0, + completion_tokens: + (lastUsageMetadata.candidatesTokenCount || 0) + + (lastUsageMetadata.thoughtsTokenCount || 0), total_tokens: (lastUsageMetadata.promptTokenCount || 0) + - (lastUsageMetadata.candidatesTokenCount || 0), + (lastUsageMetadata.candidatesTokenCount || 0) + + (lastUsageMetadata.thoughtsTokenCount || 0), }, }; pushChunk(usageChunk); @@ -1213,10 +1226,42 @@ export class ProxyService { const model = response.model; const choice = response.choices?.[0]; const finishReason = choice?.finish_reason ?? 'stop'; + const toolCalls = choice?.message?.tool_calls; const content = choice?.message && isString(choice.message.content) ? choice.message.content : ''; 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) { const finishChunk = { id: streamId, @@ -1623,12 +1668,14 @@ export class ProxyService { object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: model, + system_fingerprint: 'fp_gemininexus', choices: [ { index: 0, message: { 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, }, finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(