Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
push:
branches: ['**']
pull_request:

jobs:
gemini-compat-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install dependencies
run: npm install

- name: Run Gemini compatibility tests
run: npm run test:gemini-compat
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ yarn gen:types:watch
## License

[MIT](LICENSE)

## Gemini CLI -> ChatGPT-style API compatibility

A helper was added at `server/src/modules/common/geminiChatCompat.ts` to bridge Gemini-style request/response shapes to OpenAI Chat Completions-compatible payloads.

- `toGeminiContents(messages)` converts ChatGPT-style messages (`system`/`user`/`assistant`) into Gemini `contents`.
- `toOpenAIChatCompletion(geminiResponse, model, id?)` converts Gemini response candidates and usage metadata into a Chat Completions response object.
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"start": "ts-node src/index.ts",
"watch": "nodemon -e ts -w ./src -x npm run watch:serve",
"watch:serve": "ts-node src/index.ts"
"watch:serve": "ts-node src/index.ts",
"test:gemini-compat": "ts-node src/modules/common/geminiChatCompat.test.ts"
},
"devDependencies": {
"@babel/preset-env": "^7.4.5",
Expand Down
60 changes: 60 additions & 0 deletions server/src/modules/common/geminiChatCompat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { strict as assert } from 'assert'
import {
toGeminiContents,
toOpenAIChatCompletion,
OpenAIChatMessage,
GeminiGenerateContentResponse,
} from './geminiChatCompat'

const messages: OpenAIChatMessage[] = [
{ role: 'system', content: 'You are concise.' },
{ role: 'user', content: 'Say hello' },
{ role: 'assistant', content: 'Hello' },
]

const geminiContents = toGeminiContents(messages)
assert.equal(geminiContents.length, 3)
assert.equal(geminiContents[0].role, 'user')
assert.ok(
geminiContents[0].parts[0].text &&
geminiContents[0].parts[0].text.indexOf('System instructions:') === 0
)
assert.equal(geminiContents[1].role, 'user')
assert.equal(geminiContents[1].parts[0].text, 'Say hello')
assert.equal(geminiContents[2].role, 'model')
assert.equal(geminiContents[2].parts[0].text, 'Hello')

const geminiResponse: GeminiGenerateContentResponse = {
candidates: [
{
finishReason: 'MAX_TOKENS',
content: {
role: 'model',
parts: [{ text: 'Hi ' }, { text: 'there' }],
},
},
],
usageMetadata: {
promptTokenCount: 3,
candidatesTokenCount: 2,
totalTokenCount: 5,
},
}

const chatCompletion = toOpenAIChatCompletion(geminiResponse, 'gpt-compat')
assert.equal(chatCompletion.object, 'chat.completion')
assert.equal(chatCompletion.model, 'gpt-compat')
assert.equal(chatCompletion.choices[0].message.role, 'assistant')
assert.equal(chatCompletion.choices[0].message.content, 'Hi there')
assert.equal(chatCompletion.choices[0].finish_reason, 'length')
assert.equal(chatCompletion.usage.prompt_tokens, 3)
assert.equal(chatCompletion.usage.completion_tokens, 2)
assert.equal(chatCompletion.usage.total_tokens, 5)

const emptyResponse = toOpenAIChatCompletion({}, 'gpt-compat', 'chatcmpl-fixed')
assert.equal(emptyResponse.id, 'chatcmpl-fixed')
assert.equal(emptyResponse.choices[0].message.content, '')
assert.equal(emptyResponse.choices[0].finish_reason, 'stop')
assert.equal(emptyResponse.usage.total_tokens, 0)

console.log('geminiChatCompat tests passed')
115 changes: 115 additions & 0 deletions server/src/modules/common/geminiChatCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
export type OpenAIChatRole = 'system' | 'user' | 'assistant' | 'tool'

export interface OpenAIChatMessage {
role: OpenAIChatRole
content: string
}

export interface GeminiContentPart {
text?: string
}

export interface GeminiContent {
role?: 'user' | 'model'
parts: GeminiContentPart[]
}

export interface GeminiGenerateContentResponse {
candidates?: Array<{
content?: GeminiContent
finishReason?: string
}>
usageMetadata?: {
promptTokenCount?: number
candidatesTokenCount?: number
totalTokenCount?: number
}
modelVersion?: string
}

export const toGeminiContents = (messages: OpenAIChatMessage[]): GeminiContent[] => {
const systemMessages = messages.filter(message => message.role === 'system')
const nonSystem = messages.filter(message => message.role !== 'system')

const normalized: GeminiContent[] = nonSystem.map(message => ({
role: (message.role === 'assistant' ? 'model' : 'user') as 'user' | 'model',
parts: [{ text: message.content }],
}))

if (systemMessages.length === 0) {
return normalized
}

return [
{
role: 'user',
parts: [
{
text: `System instructions:\n${systemMessages
.map(message => message.content)
.join('\n\n')}`,
},
],
},
...normalized,
]
}

export const toOpenAIChatCompletion = (
geminiResponse: GeminiGenerateContentResponse,
model: string,
id: string = `chatcmpl-${Date.now()}`
) => {
const firstCandidate = geminiResponse.candidates && geminiResponse.candidates[0]
const parts =
firstCandidate && firstCandidate.content && firstCandidate.content.parts
? firstCandidate.content.parts
: []

const textContent = parts
.map(part => part.text || '')
.join('')
.trim()

return {
id,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: textContent,
},
finish_reason: normalizeFinishReason(firstCandidate && firstCandidate.finishReason),
},
],
usage: {
prompt_tokens:
(geminiResponse.usageMetadata && geminiResponse.usageMetadata.promptTokenCount) || 0,
completion_tokens:
(geminiResponse.usageMetadata && geminiResponse.usageMetadata.candidatesTokenCount) || 0,
total_tokens:
(geminiResponse.usageMetadata && geminiResponse.usageMetadata.totalTokenCount) || 0,
},
}
}

const normalizeFinishReason = (finishReason?: string): string => {
if (!finishReason) {
return 'stop'
}

switch (finishReason) {
case 'STOP':
return 'stop'
case 'MAX_TOKENS':
return 'length'
case 'SAFETY':
return 'content_filter'
default:
return 'stop'
}
}
Loading