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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ OCO_API_URL=<may be used to set proxy path to OpenAI api>
OCO_API_CUSTOM_HEADERS=<JSON string of custom HTTP headers to include in API requests>
OCO_TOKENS_MAX_INPUT=<max model token limit (default: 4096)>
OCO_TOKENS_MAX_OUTPUT=<max response tokens (default: 500)>
OCO_REASONING=<override reasoning-model auto-detection with true or false; omitted by default>
OCO_REASONING_MAX_TOKENS=<max completion token budget for reasoning models, including hidden reasoning tokens (default: 1000)>
OCO_DESCRIPTION=<postface a message with ~3 sentences description of the changes>
OCO_EMOJI=<boolean, add GitMoji>
OCO_MODEL=<either 'gpt-4o-mini' (default), 'gpt-4o', 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo', 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview' or 'gpt-4-0125-preview' or any Anthropic or Ollama model or any string basically, but it should be a valid model name>
Expand Down
618 changes: 333 additions & 285 deletions out/cli.cjs

Large diffs are not rendered by default.

54 changes: 53 additions & 1 deletion src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export enum CONFIG_KEYS {
OCO_OMIT_SCOPE = 'OCO_OMIT_SCOPE',
OCO_GITPUSH = 'OCO_GITPUSH', // todo: deprecate
OCO_HOOK_AUTO_UNCOMMENT = 'OCO_HOOK_AUTO_UNCOMMENT',
OCO_REASONING_MAX_TOKENS = 'OCO_REASONING_MAX_TOKENS',
OCO_REASONING = 'OCO_REASONING',
OCO_OLLAMA_THINK = 'OCO_OLLAMA_THINK'
}

Expand Down Expand Up @@ -606,7 +608,8 @@ const getDefaultModel = (provider: string | undefined): string => {

export enum DEFAULT_TOKEN_LIMITS {
DEFAULT_MAX_TOKENS_INPUT = 4096,
DEFAULT_MAX_TOKENS_OUTPUT = 500
DEFAULT_MAX_TOKENS_OUTPUT = 500,
DEFAULT_MAX_REASONING = 1000
}

const validateConfig = (
Expand All @@ -625,6 +628,19 @@ const validateConfig = (
}
};

const parsePositiveInteger = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
}

if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
return undefined;
}

const parsedValue = Number(value);
return Number.isSafeInteger(parsedValue) ? parsedValue : undefined;
};

export const configValidators = {
[CONFIG_KEYS.OCO_API_KEY](value: any, config: any = {}) {
if (config.OCO_AI_PROVIDER !== 'openai') return value;
Expand Down Expand Up @@ -846,8 +862,24 @@ export const configValidators = {
typeof value === 'boolean',
'Must be true or false'
);
},
[CONFIG_KEYS.OCO_REASONING](value: any) {
validateConfig(
CONFIG_KEYS.OCO_REASONING,
typeof value === 'boolean',
'Must be true or false'
);
return value;
},
[CONFIG_KEYS.OCO_REASONING_MAX_TOKENS](value: any) {
const parsedValue = parsePositiveInteger(value);
validateConfig(
CONFIG_KEYS.OCO_REASONING_MAX_TOKENS,
parsedValue !== undefined,
'Must be a positive integer'
);
return parsedValue!;
},

[CONFIG_KEYS.OCO_OLLAMA_THINK](value: any) {
validateConfig(
Expand Down Expand Up @@ -907,6 +939,8 @@ export type ConfigType = {
[CONFIG_KEYS.OCO_OMIT_SCOPE]: boolean;
[CONFIG_KEYS.OCO_TEST_MOCK_TYPE]: string;
[CONFIG_KEYS.OCO_HOOK_AUTO_UNCOMMENT]: boolean;
[CONFIG_KEYS.OCO_REASONING]?: boolean;
[CONFIG_KEYS.OCO_REASONING_MAX_TOKENS]?: number;
[CONFIG_KEYS.OCO_OLLAMA_THINK]?: boolean;
};

Expand Down Expand Up @@ -944,6 +978,7 @@ enum OCO_PROMPT_MODULE_ENUM {
export const DEFAULT_CONFIG = {
OCO_TOKENS_MAX_INPUT: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT,
OCO_TOKENS_MAX_OUTPUT: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT,
OCO_REASONING_MAX_TOKENS: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_REASONING,
OCO_DESCRIPTION: false,
OCO_EMOJI: false,
OCO_MODEL: getDefaultModel('openai'),
Expand All @@ -957,6 +992,7 @@ export const DEFAULT_CONFIG = {
OCO_OMIT_SCOPE: false,
OCO_GITPUSH: true, // todo: deprecate
OCO_HOOK_AUTO_UNCOMMENT: false
// OCO_REASONING: is intentionally omitted to default to 'undefined' and preserve auto-detection.
};

const initGlobalConfig = (configPath: string = defaultConfigPath) => {
Expand Down Expand Up @@ -997,6 +1033,10 @@ const getEnvConfig = (envPath: string) => {
OCO_ONE_LINE_COMMIT: parseConfigVarValue(process.env.OCO_ONE_LINE_COMMIT),
OCO_TEST_MOCK_TYPE: process.env.OCO_TEST_MOCK_TYPE,
OCO_OMIT_SCOPE: parseConfigVarValue(process.env.OCO_OMIT_SCOPE),
OCO_REASONING_MAX_TOKENS: parseConfigVarValue(
process.env.OCO_REASONING_MAX_TOKENS
),
OCO_REASONING: parseConfigVarValue(process.env.OCO_REASONING),

OCO_GITPUSH: parseConfigVarValue(process.env.OCO_GITPUSH) // todo: deprecate
};
Expand Down Expand Up @@ -1222,6 +1262,18 @@ function getConfigKeyDetails(key) {
description: 'Automatically uncomment the commit message in the hook',
values: ['true', 'false']
};
case CONFIG_KEYS.OCO_REASONING:
return {
description:
'Override automatic reasoning-model detection for the selected model',
values: ['true', 'false']
};
case CONFIG_KEYS.OCO_REASONING_MAX_TOKENS:
return {
description:
'Max completion token budget for reasoning models, including hidden reasoning tokens',
values: ['Any positive integer']
};
default:
return {
description: 'String value',
Expand Down
2 changes: 2 additions & 0 deletions src/engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface AiEngineConfig {
baseURL?: string;
proxy?: string | null;
customHeaders?: Record<string, string>;
tokensMaxReasoning?: number;
isReasoning?: boolean;
ollamaThink?: boolean;
}

Expand Down
20 changes: 14 additions & 6 deletions src/engine/openAi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,18 @@ export class OpenAiEngine implements AiEngine {
public generateCommitMessage = async (
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
): Promise<string | null> => {
const isReasoningModel = /^(o[1-9]|gpt-5)/.test(this.config.model);
const isReasoningModel =
typeof this.config.isReasoning === 'boolean'
? this.config.isReasoning
: /^(o[1-9]|gpt-5)/.test(this.config.model);

const reasoningTokens = this.config.tokensMaxReasoning || 1000;

const params = {
model: this.config.model,
messages,
...(isReasoningModel
? { max_completion_tokens: this.config.maxTokensOutput }
? { max_completion_tokens: reasoningTokens }
: {
temperature: 0,
top_p: 0.1,
Expand All @@ -70,10 +75,13 @@ export class OpenAiEngine implements AiEngine {
.map((msg) => tokenCount(msg.content as string) + 4)
.reduce((a, b) => a + b, 0);

if (
REQUEST_TOKENS >
this.config.maxTokensInput - this.config.maxTokensOutput
)
const maxInputLimit = this.config.maxTokensInput;

const maxOutPutLimit = isReasoningModel
? reasoningTokens
: this.config.maxTokensOutput;

if (REQUEST_TOKENS > maxInputLimit - maxOutPutLimit)
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);

const completion = await this.client.chat.completions.create(
Expand Down
31 changes: 21 additions & 10 deletions src/generateCommitMessageFromGitDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ import {
tokenCountAsync
} from './utils/tokenCount';

const config = getConfig();
const MAX_TOKENS_INPUT = config.OCO_TOKENS_MAX_INPUT;
const MAX_TOKENS_OUTPUT = config.OCO_TOKENS_MAX_OUTPUT;

const generateCommitMessageChatCompletionPrompt = async (
diff: string,
fullGitMojiSpec: boolean,
Expand Down Expand Up @@ -163,11 +159,26 @@ export const generateCommitMessageByDiff = async (
(msg) => tokenCount(msg.content as string) + 4
).reduce((a, b) => a + b, 0);

const isReasoningModel =
typeof currentConfig.OCO_REASONING === 'boolean'
? currentConfig.OCO_REASONING
: /^(o[1-9]|gpt-5)/.test(currentModel);

const maxInputTokens =
currentConfig.OCO_TOKENS_MAX_INPUT ??
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;

const maxOutputTokens = isReasoningModel
? currentConfig.OCO_REASONING_MAX_TOKENS ??
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_REASONING
: currentConfig.OCO_TOKENS_MAX_OUTPUT ??
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;

const MAX_REQUEST_TOKENS =
MAX_TOKENS_INPUT -
maxInputTokens -
ADJUSTMENT_FACTOR -
INIT_MESSAGES_PROMPT_LENGTH -
MAX_TOKENS_OUTPUT;
maxOutputTokens;

if ((await tokenCountAsync(diff)) >= MAX_REQUEST_TOKENS) {
const commitMessageTasks = await getCommitMessageTasksFromFileDiffs(
Expand All @@ -182,12 +193,12 @@ export const generateCommitMessageByDiff = async (
MAX_CONCURRENT_GENERATIONS
);

// When OCO_ONE_LINE_COMMIT is enabled, combine the first line of each
// split-diff message into a single line instead of joining with '\n\n'.
if (config.OCO_ONE_LINE_COMMIT) {
// Keep one-line mode intact when a large diff is split into multiple
// requests by combining the subject from each generated message.
if (currentConfig.OCO_ONE_LINE_COMMIT) {
return commitMessages
.filter(Boolean)
.map((msg) => msg!.split('\n')[0].trim())
.map((message) => message!.split('\n')[0].trim())
.join('; ');
}

Expand Down
5 changes: 3 additions & 2 deletions src/utils/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import { OpenRouterEngine } from '../engine/openrouter';
import { parseCustomHeaders } from './customHeaders';
import { resolveProxy } from './proxy';

export function getEngine(): AiEngine {
const config = getConfig();
export function getEngine(config = getConfig()): AiEngine {
const provider = config.OCO_AI_PROVIDER;

const customHeaders = parseCustomHeaders(config.OCO_API_CUSTOM_HEADERS);
Expand All @@ -31,6 +30,8 @@ export function getEngine(): AiEngine {
baseURL: config.OCO_API_URL!,
proxy: resolvedProxy,
apiKey: config.OCO_API_KEY!,
isReasoning: config.OCO_REASONING,
tokensMaxReasoning: config.OCO_REASONING_MAX_TOKENS,
customHeaders
};

Expand Down
49 changes: 46 additions & 3 deletions test/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,16 @@ describe('config', () => {
globalConfigFile = await generateConfig('.opencommit', {
OCO_TOKENS_MAX_INPUT: '4096',
OCO_TOKENS_MAX_OUTPUT: '500',
OCO_GITPUSH: 'true'
OCO_GITPUSH: 'true',
OCO_REASONING: 'false'
});

envConfigFile = await generateConfig('.env', {
OCO_TOKENS_MAX_INPUT: '8192',
OCO_ONE_LINE_COMMIT: 'false',
OCO_OMIT_SCOPE: 'true'
OCO_OMIT_SCOPE: 'true',
OCO_REASONING: 'true',
OCO_REASONING_MAX_TOKENS: '2048'
});

const config = getConfig({
Expand All @@ -121,6 +124,8 @@ describe('config', () => {
expect(config.OCO_GITPUSH).toEqual(true);
expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
expect(config.OCO_OMIT_SCOPE).toEqual(true);
expect(config.OCO_REASONING).toEqual(true);
expect(config.OCO_REASONING_MAX_TOKENS).toEqual(2048);
});

it('should handle custom HTTP headers correctly', async () => {
Expand Down Expand Up @@ -202,6 +207,8 @@ describe('config', () => {

expect(config).not.toEqual(null);
expect(config.OCO_API_KEY).toEqual(undefined);
// Ensure OCO_REASONING is undefined by default (auto-detect mode)
expect(config.OCO_REASONING).toEqual(undefined);
});

it('should not create a global config file when only reading defaults', async () => {
Expand Down Expand Up @@ -308,7 +315,9 @@ describe('config', () => {
[
[CONFIG_KEYS.OCO_TOKENS_MAX_INPUT, '8192'],
[CONFIG_KEYS.OCO_DESCRIPTION, 'true'],
[CONFIG_KEYS.OCO_ONE_LINE_COMMIT, 'false']
[CONFIG_KEYS.OCO_ONE_LINE_COMMIT, 'false'],
[CONFIG_KEYS.OCO_REASONING, 'true'],
[CONFIG_KEYS.OCO_REASONING_MAX_TOKENS, '1024']
],
globalConfigFile.filePath
);
Expand All @@ -319,6 +328,8 @@ describe('config', () => {
expect(config.OCO_TOKENS_MAX_INPUT).toEqual(8192);
expect(config.OCO_DESCRIPTION).toEqual(true);
expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
expect(config.OCO_REASONING).toEqual(true);
expect(config.OCO_REASONING_MAX_TOKENS).toEqual(1024);
});

it('should throw an error for unsupported config keys', async () => {
Expand Down Expand Up @@ -386,5 +397,37 @@ describe('config', () => {
expect(config.OCO_PROXY).toEqual(null);
expect(fileContent).toContain('OCO_PROXY=null');
});

it('should validate OCO_REASONING_MAX_TOKENS as a positive integer', async () => {
globalConfigFile = await generateConfig('.opencommit', {});

await setConfig(
[[CONFIG_KEYS.OCO_REASONING_MAX_TOKENS, '1024']],
globalConfigFile.filePath
);
let config = getConfig({ globalPath: globalConfigFile.filePath });
expect(config.OCO_REASONING_MAX_TOKENS).toEqual(1024);

const invalidValues = [
'0',
0,
'-10',
-10,
'10.5',
10.5,
'100abc',
'invalid',
'0x10',
'1e3',
' 1000 ',
true,
Number.MAX_SAFE_INTEGER + 1
];
for (const val of invalidValues) {
expect(() =>
configValidators[CONFIG_KEYS.OCO_REASONING_MAX_TOKENS](val)
).toThrow();
}
});
});
});
Loading