diff --git a/__tests__/src/api/openai.test.ts b/__tests__/src/api/openai.test.ts index 08647cd..b15358f 100644 --- a/__tests__/src/api/openai.test.ts +++ b/__tests__/src/api/openai.test.ts @@ -1,13 +1,16 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; // Mock OpenAI client to capture options and simulate chat completions -const createMock = jest.fn(); +const chatCreateMock = jest.fn(); +const responsesCreateMock = jest.fn(); class MockOpenAI { options: any; chat: any; + responses: any; constructor(options: any) { this.options = options; - this.chat = { completions: { create: createMock } }; + this.chat = { completions: { create: chatCreateMock } }; + this.responses = { create: responsesCreateMock }; } } jest.unstable_mockModule('openai', () => ({ @@ -21,7 +24,8 @@ const { getOpenAIClient, generateCommitMessage } = await import( ); beforeEach(() => { - createMock.mockReset(); + chatCreateMock.mockReset(); + responsesCreateMock.mockReset(); }); describe('getOpenAIClient', () => { @@ -54,8 +58,8 @@ describe('generateCommitMessage', () => { const userPrompt = 'Test prompt'; it('returns a valid one-line commit message', async () => { - createMock.mockResolvedValueOnce({ - choices: [{ message: { content: 'feat: add feature\nDetails' } }], + responsesCreateMock.mockResolvedValueOnce({ + output_text: 'feat: add feature\nDetails', }); const result = await generateCommitMessage( changedFiles, @@ -64,13 +68,30 @@ describe('generateCommitMessage', () => { config, ); expect(result).toBe('feat: add feature'); - expect(createMock).toHaveBeenCalledWith( + expect(responsesCreateMock).toHaveBeenCalledWith( expect.objectContaining({ model: 'commit-model' }), ); }); + it('falls back to chat completions when responses API fails', async () => { + responsesCreateMock.mockRejectedValueOnce(new Error('responses error')); + chatCreateMock.mockResolvedValueOnce({ + choices: [{ message: { content: 'fix: use chat fallback' } }], + }); + const result = await generateCommitMessage( + changedFiles, + userPrompt, + {}, + config, + ); + expect(result).toBe('fix: use chat fallback'); + }); + it('falls back to PR message on empty commit', async () => { - createMock.mockResolvedValueOnce({ + responsesCreateMock.mockResolvedValueOnce({ + output_text: '', + }); + chatCreateMock.mockResolvedValueOnce({ choices: [{ message: { content: '' } }], }); const result = await generateCommitMessage( @@ -84,8 +105,8 @@ describe('generateCommitMessage', () => { it('falls back to Issue message on too long commit', async () => { const longContent = 'a'.repeat(101); - createMock.mockResolvedValueOnce({ - choices: [{ message: { content: longContent } }], + responsesCreateMock.mockResolvedValueOnce({ + output_text: longContent, }); const result = await generateCommitMessage( changedFiles, @@ -97,7 +118,10 @@ describe('generateCommitMessage', () => { }); it('falls back to file count message when no context', async () => { - createMock.mockResolvedValueOnce({ + responsesCreateMock.mockResolvedValueOnce({ + output_text: '', + }); + chatCreateMock.mockResolvedValueOnce({ choices: [{ message: { content: '' } }], }); const result = await generateCommitMessage( @@ -107,7 +131,10 @@ describe('generateCommitMessage', () => { config, ); expect(result).toBe('chore: apply changes to 2 files'); - createMock.mockResolvedValueOnce({ + responsesCreateMock.mockResolvedValueOnce({ + output_text: '', + }); + chatCreateMock.mockResolvedValueOnce({ choices: [{ message: { content: '' } }], }); const single = await generateCommitMessage( diff --git a/action.yml b/action.yml index e8a60a3..d4a341a 100644 --- a/action.yml +++ b/action.yml @@ -27,7 +27,7 @@ inputs: description: 'OpenAI model identifier to use (default: gpt-5.3-codex)' required: false openai-commit-message-model: - description: 'OpenAI model identifier used to generate commit messages (default: gpt-5.2-codex)' + description: 'OpenAI model identifier used to generate commit messages (default: gpt-4.1-mini)' required: false direct-prompt: description: 'One-shot prompt for automated workflows. Bypasses comment triggers.' diff --git a/docs/configuration.md b/docs/configuration.md index 15a21f3..23643db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,7 +21,7 @@ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `openai-base-url` | OpenAI API base URL | | `openai-model` | OpenAI model identifier to use (default: `gpt-5.3-codex`) | -| `openai-commit-message-model` | OpenAI model identifier used to generate commit messages (default: `gpt-5.2-codex`) | +| `openai-commit-message-model` | OpenAI model identifier used to generate commit messages (default: `gpt-4.1-mini`) | | `trigger-phrase` | Custom trigger phrase to invoke Codez (default: `/codex`) | | `assignee-trigger` | Comma-separated list of GitHub usernames to trigger Codez on issue assignment | | `codex-env` | Custom environment variables to inject into the Codex CLI execution context. Accepts either a YAML mapping (multiline) or comma-separated key=value pairs. | diff --git a/src/api/openai.ts b/src/api/openai.ts index 58ed1f6..c53f931 100644 --- a/src/api/openai.ts +++ b/src/api/openai.ts @@ -16,7 +16,7 @@ import { ParseError } from '../utils/errors'; */ export const defaultModel = 'gpt-5.3-codex'; -export const defaultCommitMessageModel = 'gpt-5.2-codex'; +export const defaultCommitMessageModel = 'gpt-4.1-mini'; /** * Create and configure an OpenAI API client instance. @@ -58,16 +58,12 @@ export async function generateCommitMessage( const openai = getOpenAIClient(config); try { - const response = await openai.chat.completions.create({ - model: config.openaiCommitMessageModel, - max_completion_tokens: MAX_COMPLETION_TOKENS, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userMessage }, - ], - }); - - const content = response.choices?.[0]?.message?.content?.trim() ?? ''; + const content = await generateCommitMessageCompletion( + openai, + config.openaiCommitMessageModel, + systemPrompt, + userMessage, + ); const subject = content.split(/\r?\n/)[0] || ''; if (!subject || subject.length > MAX_SUBJECT_LENGTH) { throw new ParseError(`Invalid commit message: "${subject}"`); @@ -85,6 +81,52 @@ export async function generateCommitMessage( } } +async function generateCommitMessageCompletion( + openai: OpenAI, + model: string, + systemPrompt: string, + userMessage: string, +): Promise { + let responsesError: unknown | undefined; + try { + const response = await openai.responses.create({ + model, + instructions: systemPrompt, + input: userMessage, + max_output_tokens: MAX_COMPLETION_TOKENS, + }); + const content = response.output_text?.trim() ?? ''; + if (content) { + return content; + } + throw new ParseError('OpenAI responses API returned empty commit message'); + } catch (error) { + responsesError = error; + } + + try { + const response = await openai.chat.completions.create({ + model, + max_completion_tokens: MAX_COMPLETION_TOKENS, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }); + return response.choices?.[0]?.message?.content?.trim() ?? ''; + } catch (chatError) { + const responseErrorMessage = + responsesError instanceof Error + ? responsesError.message + : String(responsesError); + const chatErrorMessage = + chatError instanceof Error ? chatError.message : String(chatError); + throw new Error( + `OpenAI responses API failed (${responseErrorMessage}) and chat completions failed (${chatErrorMessage})`, + ); + } +} + /** Assemble the user prompt for OpenAI including file changes and context. */ function buildUserMessage( changedFiles: string[],