add custom HTTP headers support via OCO_API_CUSTOM_HEADERS

Add OCO_API_CUSTOM_HEADERS variable to README, config enum,
and env parsing to allow JSON string of custom headers.
Validate that custom headers are valid JSON in config validator.
Extend AiEngineConfig with customHeaders and pass headers to
OllamaEngine and OpenAiEngine clients when creating requests.
Parse custom headers in utils/engine and warn on invalid format.
Add unit tests to ensure OCO_API_CUSTOM_HEADERS is handled
correctly and merged from env over global config.

This enables users to send additional headers such as
Authorization or tracing headers with LLM API calls.
This commit is contained in:
EmilienMottet
2025-04-29 20:51:24 +02:00
parent 25c6a0d5d4
commit 6c48c935e2
7 changed files with 99 additions and 7 deletions
+1
View File
@@ -109,6 +109,7 @@ Create a `.env` file and add OpenCommit config variables there like this:
OCO_AI_PROVIDER=<openai (default), anthropic, azure, ollama, gemini, flowise, deepseek> OCO_AI_PROVIDER=<openai (default), anthropic, azure, ollama, gemini, flowise, deepseek>
OCO_API_KEY=<your OpenAI API token> // or other LLM provider API token OCO_API_KEY=<your OpenAI API token> // or other LLM provider API token
OCO_API_URL=<may be used to set proxy path to OpenAI api> 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_INPUT=<max model token limit (default: 4096)>
OCO_TOKENS_MAX_OUTPUT=<max response tokens (default: 500)> OCO_TOKENS_MAX_OUTPUT=<max response tokens (default: 500)>
OCO_DESCRIPTION=<postface a message with ~3 sentences description of the changes> OCO_DESCRIPTION=<postface a message with ~3 sentences description of the changes>
+19
View File
@@ -25,6 +25,7 @@ export enum CONFIG_KEYS {
OCO_ONE_LINE_COMMIT = 'OCO_ONE_LINE_COMMIT', OCO_ONE_LINE_COMMIT = 'OCO_ONE_LINE_COMMIT',
OCO_TEST_MOCK_TYPE = 'OCO_TEST_MOCK_TYPE', OCO_TEST_MOCK_TYPE = 'OCO_TEST_MOCK_TYPE',
OCO_API_URL = 'OCO_API_URL', OCO_API_URL = 'OCO_API_URL',
OCO_API_CUSTOM_HEADERS = 'OCO_API_CUSTOM_HEADERS',
OCO_OMIT_SCOPE = 'OCO_OMIT_SCOPE', OCO_OMIT_SCOPE = 'OCO_OMIT_SCOPE',
OCO_GITPUSH = 'OCO_GITPUSH' // todo: deprecate OCO_GITPUSH = 'OCO_GITPUSH' // todo: deprecate
} }
@@ -204,6 +205,22 @@ export const configValidators = {
return value; return value;
}, },
[CONFIG_KEYS.OCO_API_CUSTOM_HEADERS](value) {
try {
// Custom headers must be a valid JSON string
if (typeof value === 'string') {
JSON.parse(value);
}
return value;
} catch (error) {
validateConfig(
CONFIG_KEYS.OCO_API_CUSTOM_HEADERS,
false,
'Must be a valid JSON string of headers'
);
}
},
[CONFIG_KEYS.OCO_TOKENS_MAX_INPUT](value: any) { [CONFIG_KEYS.OCO_TOKENS_MAX_INPUT](value: any) {
value = parseInt(value); value = parseInt(value);
validateConfig( validateConfig(
@@ -380,6 +397,7 @@ export type ConfigType = {
[CONFIG_KEYS.OCO_TOKENS_MAX_INPUT]: number; [CONFIG_KEYS.OCO_TOKENS_MAX_INPUT]: number;
[CONFIG_KEYS.OCO_TOKENS_MAX_OUTPUT]: number; [CONFIG_KEYS.OCO_TOKENS_MAX_OUTPUT]: number;
[CONFIG_KEYS.OCO_API_URL]?: string; [CONFIG_KEYS.OCO_API_URL]?: string;
[CONFIG_KEYS.OCO_API_CUSTOM_HEADERS]?: string;
[CONFIG_KEYS.OCO_DESCRIPTION]: boolean; [CONFIG_KEYS.OCO_DESCRIPTION]: boolean;
[CONFIG_KEYS.OCO_EMOJI]: boolean; [CONFIG_KEYS.OCO_EMOJI]: boolean;
[CONFIG_KEYS.OCO_WHY]: boolean; [CONFIG_KEYS.OCO_WHY]: boolean;
@@ -462,6 +480,7 @@ const getEnvConfig = (envPath: string) => {
OCO_MODEL: process.env.OCO_MODEL, OCO_MODEL: process.env.OCO_MODEL,
OCO_API_URL: process.env.OCO_API_URL, OCO_API_URL: process.env.OCO_API_URL,
OCO_API_KEY: process.env.OCO_API_KEY, OCO_API_KEY: process.env.OCO_API_KEY,
OCO_API_CUSTOM_HEADERS: process.env.OCO_API_CUSTOM_HEADERS,
OCO_AI_PROVIDER: process.env.OCO_AI_PROVIDER as OCO_AI_PROVIDER_ENUM, OCO_AI_PROVIDER: process.env.OCO_AI_PROVIDER as OCO_AI_PROVIDER_ENUM,
OCO_TOKENS_MAX_INPUT: parseConfigVarValue(process.env.OCO_TOKENS_MAX_INPUT), OCO_TOKENS_MAX_INPUT: parseConfigVarValue(process.env.OCO_TOKENS_MAX_INPUT),
+1
View File
@@ -11,6 +11,7 @@ export interface AiEngineConfig {
maxTokensOutput: number; maxTokensOutput: number;
maxTokensInput: number; maxTokensInput: number;
baseURL?: string; baseURL?: string;
customHeaders?: Record<string, string>;
} }
type Client = type Client =
+8 -1
View File
@@ -11,11 +11,18 @@ export class OllamaEngine implements AiEngine {
constructor(config) { constructor(config) {
this.config = config; this.config = config;
// Combine base headers with custom headers
const headers = {
'Content-Type': 'application/json',
...config.customHeaders
};
this.client = axios.create({ this.client = axios.create({
url: config.baseURL url: config.baseURL
? `${config.baseURL}/${config.apiKey}` ? `${config.baseURL}/${config.apiKey}`
: 'http://localhost:11434/api/chat', : 'http://localhost:11434/api/chat',
headers: { 'Content-Type': 'application/json' } headers
}); });
} }
+28 -5
View File
@@ -14,11 +14,34 @@ export class OpenAiEngine implements AiEngine {
constructor(config: OpenAiConfig) { constructor(config: OpenAiConfig) {
this.config = config; this.config = config;
if (!config.baseURL) { // Configuration options for the OpenAI client
this.client = new OpenAI({ apiKey: config.apiKey }); const clientOptions: any = {
} else { apiKey: config.apiKey
this.client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL }); };
// Add baseURL if present
if (config.baseURL) {
clientOptions.baseURL = config.baseURL;
} }
// Add custom headers if present
if (config.customHeaders) {
try {
let headers = config.customHeaders;
// If the headers are a string, try to parse them as JSON
if (typeof config.customHeaders === 'string') {
headers = JSON.parse(config.customHeaders);
}
if (headers && typeof headers === 'object' && Object.keys(headers).length > 0) {
clientOptions.defaultHeaders = headers;
}
} catch (error) {
// Silently ignore parsing errors
}
}
this.client = new OpenAI(clientOptions);
} }
public generateCommitMessage = async ( public generateCommitMessage = async (
@@ -42,7 +65,7 @@ export class OpenAiEngine implements AiEngine {
this.config.maxTokensInput - this.config.maxTokensOutput this.config.maxTokensInput - this.config.maxTokensOutput
) )
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens); throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
const completion = await this.client.chat.completions.create(params); const completion = await this.client.chat.completions.create(params);
const message = completion.choices[0].message; const message = completion.choices[0].message;
+18 -1
View File
@@ -16,12 +16,29 @@ export function getEngine(): AiEngine {
const config = getConfig(); const config = getConfig();
const provider = config.OCO_AI_PROVIDER; const provider = config.OCO_AI_PROVIDER;
// Parse custom headers if provided
let customHeaders = {};
if (config.OCO_API_CUSTOM_HEADERS) {
try {
// If it's already an object, no need to parse it
if (typeof config.OCO_API_CUSTOM_HEADERS === 'object' && !Array.isArray(config.OCO_API_CUSTOM_HEADERS)) {
customHeaders = config.OCO_API_CUSTOM_HEADERS;
} else {
// Try to parse as JSON
customHeaders = JSON.parse(config.OCO_API_CUSTOM_HEADERS);
}
} catch (error) {
console.warn('Invalid OCO_API_CUSTOM_HEADERS format, ignoring custom headers');
}
}
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
model: config.OCO_MODEL!, model: config.OCO_MODEL!,
maxTokensOutput: config.OCO_TOKENS_MAX_OUTPUT!, maxTokensOutput: config.OCO_TOKENS_MAX_OUTPUT!,
maxTokensInput: config.OCO_TOKENS_MAX_INPUT!, maxTokensInput: config.OCO_TOKENS_MAX_INPUT!,
baseURL: config.OCO_API_URL!, baseURL: config.OCO_API_URL!,
apiKey: config.OCO_API_KEY! apiKey: config.OCO_API_KEY!,
customHeaders // Add custom headers to the configuration
}; };
switch (provider) { switch (provider) {
+24
View File
@@ -122,6 +122,30 @@ describe('config', () => {
expect(config.OCO_ONE_LINE_COMMIT).toEqual(false); expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
expect(config.OCO_OMIT_SCOPE).toEqual(true); expect(config.OCO_OMIT_SCOPE).toEqual(true);
}); });
it('should handle custom HTTP headers correctly', async () => {
globalConfigFile = await generateConfig('.opencommit', {
OCO_API_CUSTOM_HEADERS: '{"X-Global-Header": "global-value"}'
});
envConfigFile = await generateConfig('.env', {
OCO_API_CUSTOM_HEADERS: '{"Authorization": "Bearer token123", "X-Custom-Header": "test-value"}'
});
const config = getConfig({
globalPath: globalConfigFile.filePath,
envPath: envConfigFile.filePath
});
expect(config).not.toEqual(null);
expect(config.OCO_API_CUSTOM_HEADERS).toEqual('{"Authorization": "Bearer token123", "X-Custom-Header": "test-value"}');
// Verify that the JSON can be parsed correctly
const parsedHeaders = JSON.parse(config.OCO_API_CUSTOM_HEADERS);
expect(parsedHeaders).toHaveProperty('Authorization', 'Bearer token123');
expect(parsedHeaders).toHaveProperty('X-Custom-Header', 'test-value');
expect(parsedHeaders).not.toHaveProperty('X-Global-Header');
});
it('should handle empty local config correctly', async () => { it('should handle empty local config correctly', async () => {
globalConfigFile = await generateConfig('.opencommit', { globalConfigFile = await generateConfig('.opencommit', {