diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ca5ac99 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index b92a74b..b07557c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/server/package.json b/server/package.json index 3071370..ebc101b 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/modules/common/geminiChatCompat.test.ts b/server/src/modules/common/geminiChatCompat.test.ts new file mode 100644 index 0000000..97fc54c --- /dev/null +++ b/server/src/modules/common/geminiChatCompat.test.ts @@ -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') diff --git a/server/src/modules/common/geminiChatCompat.ts b/server/src/modules/common/geminiChatCompat.ts new file mode 100644 index 0000000..1757dda --- /dev/null +++ b/server/src/modules/common/geminiChatCompat.ts @@ -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' + } +}