From cd57dfc45a3da2bccb7c73af212a1a3d6618352b Mon Sep 17 00:00:00 2001 From: Gencebay Demir Date: Thu, 7 Nov 2024 13:03:02 +0300 Subject: [PATCH 1/2] Add Azure OpenAI support and refactor config handling. --- .gitignore | 3 + README.md | 39 ++++++++++- src/commands/aicommits.ts | 12 +--- src/commands/config.ts | 13 +++- src/commands/prepare-commit-msg-hook.ts | 12 +--- src/utils/config.ts | 67 +++++++++++++++---- src/utils/openai.ts | 76 ++++++++++------------ tests/specs/config.ts | 64 ++++++++++++++++++ tests/specs/openai/conventional-commits.ts | 17 ++--- 9 files changed, 216 insertions(+), 87 deletions(-) diff --git a/.gitignore b/.gitignore index 12ce412a..17ed5738 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ dist # Eslint cache .eslintcache + +# VSCode +.vscode/launch.json diff --git a/README.md b/README.md index c07a9f2e..6c02b211 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ aicommits config set OPENAI_KEY= generate=3 locale=en #### OPENAI_KEY -Required +It is required unless you opt to use Azure OpenAI. The OpenAI API key. You can retrieve it from [OpenAI API Keys page](https://platform.openai.com/account/api-keys). @@ -239,6 +239,43 @@ You can clear this option by setting it to an empty string: aicommits config set type= ``` +#### USE_AZURE + +Default: `false` + +The USE_AZURE parameter specifies whether the application will use Azure OpenAI instead of the standard OpenAI API. +It accepts true or false as values. + +```sh +aicommits config set USE_AZURE=true +``` + +You can toggle between OpenAI and Azure OpenAI using that flag. + +```sh +aicommits config set USE_AZURE=false +``` + +#### AZURE_OPENAI_KEY + +Required if USE_AZURE=`true` + +The Azure OpenAI authentication key. + +```sh +aicommits config set AZURE_OPENAI_KEY= +``` + +#### AZURE_OPENAI_ENDPOINT + +Required if USE_AZURE=`true` + +The endpoint URL for your Azure OpenAI deployment. Set it within quotation marks. + +```sh +aicommits config set AZURE_OPENAI_ENDPOINT='' +``` + ## How it works This CLI tool runs `git diff` to grab all your latest code changes, sends them to OpenAI's GPT-3, then returns the AI generated commit message. diff --git a/src/commands/aicommits.ts b/src/commands/aicommits.ts index e76739cb..dbd60c77 100644 --- a/src/commands/aicommits.ts +++ b/src/commands/aicommits.ts @@ -64,17 +64,7 @@ export default async ( s.start('The AI is analyzing your changes'); let messages: string[]; try { - messages = await generateCommitMessage( - config.OPENAI_KEY, - config.model, - config.locale, - staged.diff, - config.generate, - config['max-length'], - config.type, - config.timeout, - config.proxy - ); + messages = await generateCommitMessage(config, staged.diff); } finally { s.stop('Changes analyzed'); } diff --git a/src/commands/config.ts b/src/commands/config.ts index 408cf64c..361ebeca 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -25,7 +25,18 @@ export default command( if (mode === 'set') { await setConfigs( - keyValues.map((keyValue) => keyValue.split('=') as [string, string]) + keyValues.map((keyValue) => { + const [key, ...valueParts] = keyValue.split('='); + let value = valueParts.join('='); + + if (value.startsWith("'") && value.endsWith("'")) { + value = value.slice(1, -1); + } else if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + + return [key, value] as [string, string]; + }) ); return; } diff --git a/src/commands/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts index 234b6872..07853c13 100644 --- a/src/commands/prepare-commit-msg-hook.ts +++ b/src/commands/prepare-commit-msg-hook.ts @@ -39,17 +39,7 @@ export default () => s.start('The AI is analyzing your changes'); let messages: string[]; try { - messages = await generateCommitMessage( - config.OPENAI_KEY, - config.model, - config.locale, - staged!.diff, - config.generate, - config['max-length'], - config.type, - config.timeout, - config.proxy - ); + messages = await generateCommitMessage(config, staged!.diff); } finally { s.stop('Changes analyzed'); } diff --git a/src/utils/config.ts b/src/utils/config.ts index 0e07a59f..3d76b530 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,5 +1,5 @@ import fs from 'fs/promises'; -import path from 'path'; +import path, { parse } from 'path'; import os from 'os'; import ini from 'ini'; import type { TiktokenModel } from '@dqbd/tiktoken'; @@ -23,9 +23,8 @@ const parseAssert = (name: string, condition: any, message: string) => { const configParsers = { OPENAI_KEY(key?: string) { if (!key) { - throw new KnownError( - 'Please set your OpenAI API key via `aicommits config set OPENAI_KEY=`' - ); + // if USE_AZURE config is set to true, then we don't need to check for OPENAI_KEY + return ''; } parseAssert('OPENAI_KEY', key.startsWith('sk-'), 'Must start with "sk-"'); // Key can range from 43~51 characters. There's no spec to assert this. @@ -58,7 +57,7 @@ const configParsers = { return parsed; }, - type(type?: string) { + type(type?: string): CommitType { if (!type) { return ''; } @@ -117,14 +116,52 @@ const configParsers = { }, } as const; -type ConfigKeys = keyof typeof configParsers; +const azureConfigParsers = { + USE_AZURE(useAzure?: string) { + if (useAzure === undefined) { + return false; + } + const normalizedValue = String(useAzure).toLowerCase(); + parseAssert( + 'USE_AZURE', + normalizedValue === 'true' || normalizedValue === 'false', + 'Must be true or false' + ); + return normalizedValue === 'true'; + }, + AZURE_OPENAI_KEY(key?: string) { + if (!key) { + return ''; + } + parseAssert('AZURE_OPENAI_KEY', key.length > 0, 'Cannot be empty'); + return key; + }, + AZURE_OPENAI_ENDPOINT(endpoint?: string) { + if (!endpoint) { + return ''; + } + parseAssert( + 'AZURE_OPENAI_ENDPOINT', + /^https?:\/\//.test(endpoint), + 'Must be a valid URL' + ); + return endpoint; + }, +} as const; + +const combinedParsers = { + ...azureConfigParsers, + ...configParsers, +}; + +type ConfigKeys = keyof typeof combinedParsers; type RawConfig = { [key in ConfigKeys]?: string; }; export type ValidConfig = { - [Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>; + [Key in ConfigKeys]: ReturnType<(typeof combinedParsers)[Key]>; }; const configPath = path.join(os.homedir(), '.aicommits'); @@ -146,8 +183,8 @@ export const getConfig = async ( const config = await readConfigFile(); const parsedConfig: Record = {}; - for (const key of Object.keys(configParsers) as ConfigKeys[]) { - const parser = configParsers[key]; + for (const key of Object.keys(combinedParsers) as ConfigKeys[]) { + const parser = combinedParsers[key]; const value = cliConfig?.[key] ?? config[key]; if (suppressErrors) { @@ -159,6 +196,14 @@ export const getConfig = async ( } } + const openaiKey = parsedConfig['OPENAI_KEY'] as string; + const useAzure = parsedConfig['USE_AZURE'] as boolean; + if (openaiKey === '' && !useAzure) { + throw new KnownError( + 'Please set your OpenAI API key via `aicommits config set OPENAI_KEY=` or set your Azure OpenAI configurations.' + ); + } + return parsedConfig as ValidConfig; }; @@ -166,11 +211,11 @@ export const setConfigs = async (keyValues: [key: string, value: string][]) => { const config = await readConfigFile(); for (const [key, value] of keyValues) { - if (!hasOwn(configParsers, key)) { + if (!hasOwn(combinedParsers, key)) { throw new KnownError(`Invalid config property: ${key}`); } - const parsed = configParsers[key as ConfigKeys](value); + const parsed = combinedParsers[key as ConfigKeys](value); config[key as ConfigKeys] = parsed as any; } diff --git a/src/utils/openai.ts b/src/utils/openai.ts index d8c767ed..61d45476 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -4,18 +4,13 @@ import type { CreateChatCompletionRequest, CreateChatCompletionResponse, } from 'openai'; -import { - type TiktokenModel, - // encoding_for_model, -} from '@dqbd/tiktoken'; import createHttpsProxyAgent from 'https-proxy-agent'; import { KnownError } from './error.js'; -import type { CommitType } from './config.js'; +import type { ValidConfig } from './config.js'; import { generatePrompt } from './prompt.js'; const httpsPost = async ( - hostname: string, - path: string, + url: string, headers: Record, json: unknown, timeout: number, @@ -28,10 +23,9 @@ const httpsPost = async ( }>((resolve, reject) => { const postContent = JSON.stringify(json); const request = https.request( + url, { port: 443, - hostname, - path, method: 'POST', headers: { ...headers, @@ -68,17 +62,20 @@ const httpsPost = async ( }); const createChatCompletion = async ( + useAzure: boolean, + url: string, apiKey: string, json: CreateChatCompletionRequest, timeout: number, proxy?: string ) => { + const headers: Record = useAzure + ? { 'api-key': apiKey } + : { Authorization: `Bearer ${apiKey}` }; + const { response, data } = await httpsPost( - 'api.openai.com', - '/v1/chat/completions', - { - Authorization: `Bearer ${apiKey}`, - }, + url, + headers, json, timeout, proxy @@ -89,7 +86,7 @@ const createChatCompletion = async ( response.statusCode < 200 || response.statusCode > 299 ) { - let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`; + let errorMessage = `API Error: ${response.statusCode} - ${response.statusMessage}`; if (data) { errorMessage += `\n\n${data}`; @@ -113,37 +110,32 @@ const sanitizeMessage = (message: string) => const deduplicateMessages = (array: string[]) => Array.from(new Set(array)); -// const generateStringFromLength = (length: number) => { -// let result = ''; -// const highestTokenChar = 'z'; -// for (let i = 0; i < length; i += 1) { -// result += highestTokenChar; -// } -// return result; -// }; - -// const getTokens = (prompt: string, model: TiktokenModel) => { -// const encoder = encoding_for_model(model); -// const tokens = encoder.encode(prompt).length; -// // Free the encoder to avoid possible memory leaks. -// encoder.free(); -// return tokens; -// }; - export const generateCommitMessage = async ( - apiKey: string, - model: TiktokenModel, - locale: string, - diff: string, - completions: number, - maxLength: number, - type: CommitType, - timeout: number, - proxy?: string + config: ValidConfig, + diff: string ) => { + const { + OPENAI_KEY: apiKey, + USE_AZURE: useAzure, + AZURE_OPENAI_KEY: azureKey, + proxy, + generate: completions, + timeout, + locale, + 'max-length': maxLength, + model, + type, + } = config; + try { + const url = useAzure + ? config.AZURE_OPENAI_ENDPOINT + : 'https://api.openai.com/v1/chat/completions'; + const completion = await createChatCompletion( - apiKey, + useAzure, + url, + useAzure ? azureKey! : apiKey!, { model, messages: [ diff --git a/tests/specs/config.ts b/tests/specs/config.ts index 9bfe45fc..afb1e143 100644 --- a/tests/specs/config.ts +++ b/tests/specs/config.ts @@ -118,6 +118,70 @@ export default testSuite(({ describe }) => { expect(stdout).toBe(openAiToken); }); + await describe('USE_AZURE', ({ test }) => { + test('setting invalid USE_AZURE', async () => { + const { stderr } = await aicommits(['config', 'set', 'USE_AZURE=1'], { + reject: false, + }); + expect(stderr).toMatch(/Must be true or false/i); + }); + + test('updates config', async () => { + const defaultConfig = await aicommits(['config', 'get', 'USE_AZURE']); + expect(defaultConfig.stdout).toBe('USE_AZURE=false'); + + const useAzure = 'USE_AZURE=true'; + await aicommits(['config', 'set', useAzure]); + + const configFile = await fs.readFile(configPath, 'utf8'); + expect(configFile).toMatch(useAzure); + + const get = await aicommits(['config', 'get', 'USE_AZURE']); + expect(get.stdout).toBe(useAzure); + }); + }); + + await describe('AZURE_OPENAI_KEY', ({ test }) => { + test('updates config', async () => { + const defaultConfig = await aicommits(['config', 'get', 'AZURE_OPENAI_KEY']); + expect(defaultConfig.stdout).toBe('AZURE_OPENAI_KEY='); + + const azureOpenAIKey = 'AZURE_OPENAI_KEY=azure-key'; + await aicommits(['config', 'set', azureOpenAIKey]); + + const configFile = await fs.readFile(configPath, 'utf8'); + expect(configFile).toMatch(azureOpenAIKey); + + const get = await aicommits(['config', 'get', 'AZURE_OPENAI_KEY']); + expect(get.stdout).toBe(azureOpenAIKey); + }); + }); + + await describe('AZURE_OPENAI_ENDPOINT', ({ test }) => { + test('setting invalid AZURE_OPENAI_ENDPOINT', async () => { + const { stderr } = await aicommits( + ['config', 'set', 'AZURE_OPENAI_ENDPOINT=foo://bar'], + { reject: false } + ); + expect(stderr).toMatch(/Must be a valid URL/i); + }); + + test('updates config', async () => { + const defaultConfig = await aicommits(['config', 'get', 'AZURE_OPENAI_ENDPOINT']); + expect(defaultConfig.stdout).toBe('AZURE_OPENAI_ENDPOINT='); + + const endpoint = 'https://demo.openai.azure.com/openai/deployments/my-latest-deployment/chat/completions?api-version=2024-08-01-preview'; + const azureEndpoint = `AZURE_OPENAI_ENDPOINT='${endpoint}'`; + await aicommits(['config', 'set', azureEndpoint]); + + const configFile = await fs.readFile(configPath, 'utf8'); + expect(configFile).toMatch(`AZURE_OPENAI_ENDPOINT="${endpoint}"`); + + const get = await aicommits(['config', 'get', 'AZURE_OPENAI_ENDPOINT']); + expect(get.stdout).toBe(`AZURE_OPENAI_ENDPOINT=${endpoint}`); + }); + }); + await fixture.rm(); }); }); diff --git a/tests/specs/openai/conventional-commits.ts b/tests/specs/openai/conventional-commits.ts index 7a3e43e6..de4c9f27 100644 --- a/tests/specs/openai/conventional-commits.ts +++ b/tests/specs/openai/conventional-commits.ts @@ -132,22 +132,19 @@ export default testSuite(({ describe }) => { configOverrides: Partial = {} ): Promise { const config = { + OPENAI_KEY: OPENAI_KEY!, + model: 'gpt-3.5-turbo', locale: 'en', type: 'conventional', generate: 1, 'max-length': 50, + timeout: 7000, ...configOverrides, } as ValidConfig; - const commitMessages = await generateCommitMessage( - OPENAI_KEY!, - 'gpt-3.5-turbo', - config.locale, - gitDiff, - config.generate, - config['max-length'], - config.type, - 7000 - ); + + config.OPENAI_KEY = OPENAI_KEY!; + + const commitMessages = await generateCommitMessage(config, gitDiff); return commitMessages[0]; } From 154614627887eccacc600eda15de0eb2c0045493 Mon Sep 17 00:00:00 2001 From: Gencebay Demir Date: Thu, 7 Nov 2024 13:19:50 +0300 Subject: [PATCH 2/2] Add Azure OpenAI configuration validation and Azure OpenAI config error handling with suppress option. --- src/utils/config.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/utils/config.ts b/src/utils/config.ts index 3d76b530..3c1e9bbf 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -199,9 +199,29 @@ export const getConfig = async ( const openaiKey = parsedConfig['OPENAI_KEY'] as string; const useAzure = parsedConfig['USE_AZURE'] as boolean; if (openaiKey === '' && !useAzure) { - throw new KnownError( - 'Please set your OpenAI API key via `aicommits config set OPENAI_KEY=` or set your Azure OpenAI configurations.' - ); + if (!suppressErrors) { + throw new KnownError( + 'Please set your OpenAI API key via `aicommits config set OPENAI_KEY=` or set your Azure OpenAI configurations.' + ); + } + } + + const azureOpenaiKey = parsedConfig['AZURE_OPENAI_KEY'] as string; + const azureOpenaiEndpoint = parsedConfig['AZURE_OPENAI_ENDPOINT'] as string; + if (useAzure) { + if (!suppressErrors) { + if (azureOpenaiKey === '') { + throw new KnownError( + `Please set your Azure OpenAI configurations via aicommits config set AZURE_OPENAI_KEY=` + ); + } + + if (azureOpenaiEndpoint === '') { + throw new KnownError( + `Please set your Azure OpenAI configurations via aicommits config set AZURE_OPENAI_ENDPOINT=''` + ); + } + } } return parsedConfig as ValidConfig;