mirror of
https://github.com/di-sukharev/opencommit.git
synced 2026-04-20 03:02:51 -04:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62129503b3 | ||
|
|
f81e836f34 | ||
|
|
c3d1fb379f | ||
|
|
e17294abc7 | ||
|
|
789b4f5e9f | ||
|
|
a9c9bcfd5a | ||
|
|
0ee82f7430 | ||
|
|
9923dab532 | ||
|
|
f74ba2dfc6 | ||
|
|
53414438d1 | ||
|
|
6982e76cf5 | ||
|
|
dc7f7f6552 | ||
|
|
db8a22b0cb | ||
|
|
e27007b6fe | ||
|
|
f51393e37a | ||
|
|
83f9193749 | ||
|
|
bc608e97bd | ||
|
|
40182f26b3 | ||
|
|
62d56a5278 | ||
|
|
9e601ca6b5 | ||
|
|
4a9b1391a3 | ||
|
|
3fe71c1d23 | ||
|
|
2f2e888098 | ||
|
|
4fc8284b87 | ||
|
|
689f52b22f | ||
|
|
de5d5cbb95 | ||
|
|
9ca7c02840 | ||
|
|
6d9fff56aa | ||
|
|
6ed70d0382 | ||
|
|
5b241ed2d0 | ||
|
|
8b0ee25923 | ||
|
|
74fff2861b | ||
|
|
a0dc1c87c5 | ||
|
|
d65547dcaa |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
out/
|
||||
coverage/
|
||||
temp/
|
||||
build/
|
||||
|
||||
32
README.md
32
README.md
@@ -201,6 +201,28 @@ or for as a cheaper option:
|
||||
oco config set OCO_MODEL=gpt-3.5-turbo
|
||||
```
|
||||
|
||||
### Model Management
|
||||
|
||||
OpenCommit automatically fetches available models from your provider when you run `oco setup`. Models are cached for 7 days to reduce API calls.
|
||||
|
||||
To see available models for your current provider:
|
||||
|
||||
```sh
|
||||
oco models
|
||||
```
|
||||
|
||||
To refresh the model list (e.g., after new models are released):
|
||||
|
||||
```sh
|
||||
oco models --refresh
|
||||
```
|
||||
|
||||
To see models for a specific provider:
|
||||
|
||||
```sh
|
||||
oco models --provider anthropic
|
||||
```
|
||||
|
||||
### Switch to other LLM providers with a custom URL
|
||||
|
||||
By default OpenCommit uses [OpenAI](https://openai.com).
|
||||
@@ -215,6 +237,16 @@ oco config set OCO_AI_PROVIDER=flowise OCO_API_KEY=<your_flowise_api_key> OCO_AP
|
||||
oco config set OCO_AI_PROVIDER=ollama OCO_API_KEY=<your_ollama_api_key> OCO_API_URL=<your_ollama_endpoint>
|
||||
```
|
||||
|
||||
### Use with Proxy
|
||||
|
||||
If you are behind a proxy, you can set it in the config:
|
||||
|
||||
```sh
|
||||
oco config set OCO_PROXY=http://127.0.0.1:7890
|
||||
```
|
||||
|
||||
Or it will automatically use `HTTPS_PROXY` or `HTTP_PROXY` environment variables.
|
||||
|
||||
### Locale configuration
|
||||
|
||||
To globally specify the language used to generate commit messages:
|
||||
|
||||
@@ -19,7 +19,7 @@ const config: Config = {
|
||||
'<rootDir>/test/e2e/prompt-module/data/'
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(cli-testing-library|@clack|cleye)/.*)'
|
||||
'node_modules/(?!(cli-testing-library|@clack|cleye|chalk)/.*)'
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': [
|
||||
|
||||
775
out/cli.cjs
775
out/cli.cjs
File diff suppressed because it is too large
Load Diff
89112
out/github-action.cjs
89112
out/github-action.cjs
File diff suppressed because one or more lines are too long
Binary file not shown.
50
package-lock.json
generated
50
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opencommit",
|
||||
"version": "3.2.11",
|
||||
"version": "3.2.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opencommit",
|
||||
"version": "3.2.11",
|
||||
"version": "3.2.16",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
@@ -16,15 +16,16 @@
|
||||
"@azure/openai": "^1.0.0-beta.12",
|
||||
"@clack/prompts": "^0.6.1",
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
"@google/generative-ai": "^0.11.4",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@mistralai/mistralai": "^1.3.5",
|
||||
"@octokit/webhooks-schemas": "^6.11.0",
|
||||
"@octokit/webhooks-types": "^6.11.0",
|
||||
"axios": "^1.3.4",
|
||||
"axios": "1.9.0",
|
||||
"chalk": "^5.2.0",
|
||||
"cleye": "^1.3.2",
|
||||
"crypto": "^1.0.1",
|
||||
"execa": "^7.0.0",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ignore": "^5.2.4",
|
||||
"ini": "^3.0.1",
|
||||
"inquirer": "^9.1.4",
|
||||
@@ -185,6 +186,19 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-sse": {
|
||||
"version": "2.1.2",
|
||||
"license": "MIT",
|
||||
@@ -1395,7 +1409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.11.4",
|
||||
"version": "0.24.1",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
|
||||
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -2921,11 +2937,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
@@ -4770,16 +4785,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.4",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz",
|
||||
"integrity": "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.0.2",
|
||||
"debug": "4"
|
||||
"agent-base": "8.0.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/agent-base": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz",
|
||||
"integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "4.3.1",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencommit",
|
||||
"version": "3.2.11",
|
||||
"version": "3.2.16",
|
||||
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
|
||||
"keywords": [
|
||||
"git",
|
||||
@@ -89,15 +89,16 @@
|
||||
"@azure/openai": "^1.0.0-beta.12",
|
||||
"@clack/prompts": "^0.6.1",
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
"@google/generative-ai": "^0.11.4",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@mistralai/mistralai": "^1.3.5",
|
||||
"@octokit/webhooks-schemas": "^6.11.0",
|
||||
"@octokit/webhooks-types": "^6.11.0",
|
||||
"axios": "^1.3.4",
|
||||
"axios": "1.9.0",
|
||||
"chalk": "^5.2.0",
|
||||
"cleye": "^1.3.2",
|
||||
"crypto": "^1.0.1",
|
||||
"execa": "^7.0.0",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ignore": "^5.2.4",
|
||||
"ini": "^3.0.1",
|
||||
"inquirer": "^9.1.4",
|
||||
|
||||
48
src/cli.ts
48
src/cli.ts
@@ -5,25 +5,36 @@ import { cli } from 'cleye';
|
||||
import packageJSON from '../package.json';
|
||||
import { commit } from './commands/commit';
|
||||
import { commitlintConfigCommand } from './commands/commitlint';
|
||||
import { configCommand } from './commands/config';
|
||||
import { configCommand, getConfig } from './commands/config';
|
||||
import { hookCommand, isHookCalled } from './commands/githook.js';
|
||||
import { prepareCommitMessageHook } from './commands/prepare-commit-msg-hook';
|
||||
import { setupProxy } from './utils/proxy';
|
||||
import {
|
||||
setupCommand,
|
||||
isFirstRun,
|
||||
runSetup,
|
||||
promptForMissingApiKey
|
||||
} from './commands/setup';
|
||||
import { modelsCommand } from './commands/models';
|
||||
import { checkIsLatestVersion } from './utils/checkIsLatestVersion';
|
||||
import { runMigrations } from './migrations/_run.js';
|
||||
|
||||
const config = getConfig();
|
||||
setupProxy(config.OCO_PROXY);
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
cli(
|
||||
{
|
||||
version: packageJSON.version,
|
||||
name: 'opencommit',
|
||||
commands: [configCommand, hookCommand, commitlintConfigCommand, setupCommand],
|
||||
commands: [
|
||||
configCommand,
|
||||
hookCommand,
|
||||
commitlintConfigCommand,
|
||||
setupCommand,
|
||||
modelsCommand
|
||||
],
|
||||
flags: {
|
||||
fgm: {
|
||||
type: Boolean,
|
||||
@@ -47,28 +58,29 @@ cli(
|
||||
help: { description: packageJSON.description }
|
||||
},
|
||||
async ({ flags }) => {
|
||||
if (await isHookCalled()) {
|
||||
await prepareCommitMessageHook();
|
||||
return;
|
||||
}
|
||||
|
||||
await runMigrations();
|
||||
await checkIsLatestVersion();
|
||||
|
||||
if (await isHookCalled()) {
|
||||
prepareCommitMessageHook();
|
||||
} else {
|
||||
// Check for first run and trigger setup wizard
|
||||
if (isFirstRun()) {
|
||||
const setupComplete = await runSetup();
|
||||
if (!setupComplete) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing API key and prompt if needed
|
||||
const hasApiKey = await promptForMissingApiKey();
|
||||
if (!hasApiKey) {
|
||||
// Check for first run and trigger setup wizard
|
||||
if (isFirstRun()) {
|
||||
const setupComplete = await runSetup();
|
||||
if (!setupComplete) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
commit(extraArgs, flags.context, false, flags.fgm, flags.yes);
|
||||
}
|
||||
|
||||
// Check for missing API key and prompt if needed
|
||||
const hasApiKey = await promptForMissingApiKey();
|
||||
if (!hasApiKey) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
commit(extraArgs, flags.context, false, flags.fgm, flags.yes);
|
||||
},
|
||||
extraArgs
|
||||
);
|
||||
|
||||
@@ -2,5 +2,6 @@ export enum COMMANDS {
|
||||
config = 'config',
|
||||
hook = 'hook',
|
||||
commitlint = 'commitlint',
|
||||
setup = 'setup'
|
||||
setup = 'setup',
|
||||
models = 'models'
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import chalk from 'chalk';
|
||||
import { execa } from 'execa';
|
||||
import { generateCommitMessageByDiff } from '../generateCommitMessageFromGitDiff';
|
||||
import { formatUserFriendlyError, printFormattedError } from '../utils/errors';
|
||||
import {
|
||||
assertGitRepo,
|
||||
getChangedFiles,
|
||||
@@ -211,10 +212,11 @@ ${chalk.grey('——————————————————')}`
|
||||
`${chalk.red('✖')} Failed to generate the commit message`
|
||||
);
|
||||
|
||||
console.log(error);
|
||||
const errorConfig = getConfig();
|
||||
const provider = errorConfig.OCO_AI_PROVIDER || 'openai';
|
||||
const formatted = formatUserFriendlyError(error, provider);
|
||||
outro(printFormattedError(formatted));
|
||||
|
||||
const err = error as Error;
|
||||
outro(`${chalk.red('✖')} ${err?.message || err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum CONFIG_KEYS {
|
||||
OCO_ONE_LINE_COMMIT = 'OCO_ONE_LINE_COMMIT',
|
||||
OCO_TEST_MOCK_TYPE = 'OCO_TEST_MOCK_TYPE',
|
||||
OCO_API_URL = 'OCO_API_URL',
|
||||
OCO_PROXY = 'OCO_PROXY',
|
||||
OCO_API_CUSTOM_HEADERS = 'OCO_API_CUSTOM_HEADERS',
|
||||
OCO_OMIT_SCOPE = 'OCO_OMIT_SCOPE',
|
||||
OCO_GITPUSH = 'OCO_GITPUSH', // todo: deprecate
|
||||
@@ -727,6 +728,15 @@ export const configValidators = {
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_PROXY](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_PROXY,
|
||||
typeof value === 'string',
|
||||
`${value} is not a valid URL. It should start with 'http://' or 'https://'.`
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_MODEL](value: any, config: any = {}) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_MODEL,
|
||||
@@ -849,7 +859,8 @@ export enum OCO_AI_PROVIDER_ENUM {
|
||||
|
||||
export const PROVIDER_API_KEY_URLS: Record<string, string | null> = {
|
||||
[OCO_AI_PROVIDER_ENUM.OPENAI]: 'https://platform.openai.com/api-keys',
|
||||
[OCO_AI_PROVIDER_ENUM.ANTHROPIC]: 'https://console.anthropic.com/settings/keys',
|
||||
[OCO_AI_PROVIDER_ENUM.ANTHROPIC]:
|
||||
'https://console.anthropic.com/settings/keys',
|
||||
[OCO_AI_PROVIDER_ENUM.GEMINI]: 'https://aistudio.google.com/app/apikey',
|
||||
[OCO_AI_PROVIDER_ENUM.GROQ]: 'https://console.groq.com/keys',
|
||||
[OCO_AI_PROVIDER_ENUM.MISTRAL]: 'https://console.mistral.ai/api-keys/',
|
||||
@@ -872,13 +883,14 @@ export const RECOMMENDED_MODELS: Record<string, string> = {
|
||||
[OCO_AI_PROVIDER_ENUM.DEEPSEEK]: 'deepseek-chat',
|
||||
[OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'openai/gpt-4o-mini',
|
||||
[OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
|
||||
export type ConfigType = {
|
||||
[CONFIG_KEYS.OCO_API_KEY]?: string;
|
||||
[CONFIG_KEYS.OCO_TOKENS_MAX_INPUT]: number;
|
||||
[CONFIG_KEYS.OCO_TOKENS_MAX_OUTPUT]: number;
|
||||
[CONFIG_KEYS.OCO_API_URL]?: string;
|
||||
[CONFIG_KEYS.OCO_PROXY]?: string;
|
||||
[CONFIG_KEYS.OCO_API_CUSTOM_HEADERS]?: string;
|
||||
[CONFIG_KEYS.OCO_DESCRIPTION]: boolean;
|
||||
[CONFIG_KEYS.OCO_EMOJI]: boolean;
|
||||
@@ -963,6 +975,10 @@ const getEnvConfig = (envPath: string) => {
|
||||
return {
|
||||
OCO_MODEL: process.env.OCO_MODEL,
|
||||
OCO_API_URL: process.env.OCO_API_URL,
|
||||
OCO_PROXY:
|
||||
process.env.OCO_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.HTTP_PROXY,
|
||||
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,
|
||||
@@ -1188,6 +1204,11 @@ function getConfigKeyDetails(key) {
|
||||
'Custom API URL - may be used to set proxy path to OpenAI API',
|
||||
values: ["URL string (must start with 'http://' or 'https://')"]
|
||||
};
|
||||
case CONFIG_KEYS.OCO_PROXY:
|
||||
return {
|
||||
description: 'HTTP/HTTPS Proxy URL',
|
||||
values: ["URL string (must start with 'http://' or 'https://')"]
|
||||
};
|
||||
case CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER:
|
||||
return {
|
||||
description: 'Message template placeholder',
|
||||
|
||||
155
src/commands/models.ts
Normal file
155
src/commands/models.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { intro, outro, spinner } from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { command } from 'cleye';
|
||||
import { COMMANDS } from './ENUMS';
|
||||
import { MODEL_LIST, OCO_AI_PROVIDER_ENUM, getConfig } from './config';
|
||||
import {
|
||||
fetchModelsForProvider,
|
||||
clearModelCache,
|
||||
getCacheInfo,
|
||||
getCachedModels
|
||||
} from '../utils/modelCache';
|
||||
|
||||
function formatCacheAge(timestamp: number | null): string {
|
||||
if (!timestamp) return 'never';
|
||||
const ageMs = Date.now() - timestamp;
|
||||
const days = Math.floor(ageMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor(ageMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(ageMs / (1000 * 60));
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
async function listModels(
|
||||
provider: string,
|
||||
useCache: boolean = true
|
||||
): Promise<void> {
|
||||
const config = getConfig();
|
||||
const apiKey = config.OCO_API_KEY;
|
||||
const currentModel = config.OCO_MODEL;
|
||||
|
||||
// Get cached models or fetch new ones
|
||||
let models: string[] = [];
|
||||
|
||||
if (useCache) {
|
||||
const cached = getCachedModels(provider);
|
||||
if (cached) {
|
||||
models = cached;
|
||||
}
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
// Fallback to hardcoded list
|
||||
const providerKey = provider.toLowerCase() as keyof typeof MODEL_LIST;
|
||||
models = MODEL_LIST[providerKey] || [];
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${chalk.bold('Available models for')} ${chalk.cyan(provider)}:\n`
|
||||
);
|
||||
|
||||
if (models.length === 0) {
|
||||
console.log(chalk.dim(' No models found'));
|
||||
} else {
|
||||
models.forEach((model) => {
|
||||
const isCurrent = model === currentModel;
|
||||
const prefix = isCurrent ? chalk.green('* ') : ' ';
|
||||
const label = isCurrent ? chalk.green(model) : model;
|
||||
console.log(`${prefix}${label}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function refreshModels(provider: string): Promise<void> {
|
||||
const config = getConfig();
|
||||
const apiKey = config.OCO_API_KEY;
|
||||
|
||||
const loadingSpinner = spinner();
|
||||
loadingSpinner.start(`Fetching models from ${provider}...`);
|
||||
|
||||
// Clear cache first
|
||||
clearModelCache();
|
||||
|
||||
try {
|
||||
const models = await fetchModelsForProvider(
|
||||
provider,
|
||||
apiKey,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
loadingSpinner.stop(`${chalk.green('+')} Fetched ${models.length} models`);
|
||||
|
||||
// List the models
|
||||
await listModels(provider, true);
|
||||
} catch (error) {
|
||||
loadingSpinner.stop(chalk.red('Failed to fetch models'));
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const modelsCommand = command(
|
||||
{
|
||||
name: COMMANDS.models,
|
||||
help: {
|
||||
description: 'List and manage cached models for your AI provider'
|
||||
},
|
||||
flags: {
|
||||
refresh: {
|
||||
type: Boolean,
|
||||
alias: 'r',
|
||||
description: 'Clear cache and re-fetch models from the provider',
|
||||
default: false
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
alias: 'p',
|
||||
description: 'Specify provider (defaults to current OCO_AI_PROVIDER)'
|
||||
}
|
||||
}
|
||||
},
|
||||
async ({ flags }) => {
|
||||
const config = getConfig();
|
||||
const provider =
|
||||
flags.provider || config.OCO_AI_PROVIDER || OCO_AI_PROVIDER_ENUM.OPENAI;
|
||||
|
||||
intro(chalk.bgCyan(' OpenCommit Models '));
|
||||
|
||||
// Show cache info
|
||||
const cacheInfo = getCacheInfo();
|
||||
if (cacheInfo.timestamp) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` Cache last updated: ${formatCacheAge(cacheInfo.timestamp)}`
|
||||
)
|
||||
);
|
||||
if (cacheInfo.providers.length > 0) {
|
||||
console.log(
|
||||
chalk.dim(` Cached providers: ${cacheInfo.providers.join(', ')}`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim(' No cached models'));
|
||||
}
|
||||
|
||||
if (flags.refresh) {
|
||||
await refreshModels(provider);
|
||||
} else {
|
||||
await listModels(provider);
|
||||
}
|
||||
|
||||
outro(`Run ${chalk.cyan('oco models --refresh')} to update the model list`);
|
||||
}
|
||||
);
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from './config';
|
||||
import {
|
||||
fetchModelsForProvider,
|
||||
fetchOllamaModels
|
||||
fetchOllamaModels,
|
||||
getCacheInfo
|
||||
} from '../utils/modelCache';
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
@@ -51,6 +52,12 @@ const OTHER_PROVIDERS = [
|
||||
];
|
||||
|
||||
const NO_API_KEY_PROVIDERS = [
|
||||
OCO_AI_PROVIDER_ENUM.OLLAMA,
|
||||
OCO_AI_PROVIDER_ENUM.MLX,
|
||||
OCO_AI_PROVIDER_ENUM.TEST
|
||||
];
|
||||
|
||||
const MODEL_REQUIRED_PROVIDERS = [
|
||||
OCO_AI_PROVIDER_ENUM.OLLAMA,
|
||||
OCO_AI_PROVIDER_ENUM.MLX
|
||||
];
|
||||
@@ -89,7 +96,8 @@ async function selectProvider(): Promise<string | symbol> {
|
||||
}
|
||||
|
||||
async function getApiKey(provider: string): Promise<string | symbol> {
|
||||
const url = PROVIDER_API_KEY_URLS[provider as keyof typeof PROVIDER_API_KEY_URLS];
|
||||
const url =
|
||||
PROVIDER_API_KEY_URLS[provider as keyof typeof PROVIDER_API_KEY_URLS];
|
||||
|
||||
let message = `Enter your ${provider} API key:`;
|
||||
if (url) {
|
||||
@@ -108,24 +116,54 @@ async function getApiKey(provider: string): Promise<string | symbol> {
|
||||
});
|
||||
}
|
||||
|
||||
function formatCacheAge(timestamp: number | null): string {
|
||||
if (!timestamp) return '';
|
||||
const ageMs = Date.now() - timestamp;
|
||||
const days = Math.floor(ageMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor(ageMs / (1000 * 60 * 60));
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
async function selectModel(
|
||||
provider: string,
|
||||
apiKey?: string
|
||||
): Promise<string | symbol> {
|
||||
const providerDisplayName =
|
||||
PROVIDER_DISPLAY_NAMES[provider]?.split(' (')[0] || provider;
|
||||
const loadingSpinner = spinner();
|
||||
loadingSpinner.start('Fetching available models...');
|
||||
loadingSpinner.start(`Fetching models from ${providerDisplayName}...`);
|
||||
|
||||
let models: string[] = [];
|
||||
let usedFallback = false;
|
||||
|
||||
try {
|
||||
models = await fetchModelsForProvider(provider, apiKey);
|
||||
} catch {
|
||||
// Fall back to hardcoded list
|
||||
usedFallback = true;
|
||||
const providerKey = provider.toLowerCase() as keyof typeof MODEL_LIST;
|
||||
models = MODEL_LIST[providerKey] || [];
|
||||
}
|
||||
|
||||
loadingSpinner.stop('Models loaded');
|
||||
// Check cache info for display
|
||||
const cacheInfo = getCacheInfo();
|
||||
const cacheAge = formatCacheAge(cacheInfo.timestamp);
|
||||
|
||||
if (usedFallback) {
|
||||
loadingSpinner.stop(
|
||||
chalk.yellow('Could not fetch models from API. Using default list.')
|
||||
);
|
||||
} else if (cacheAge) {
|
||||
loadingSpinner.stop(`Models loaded ${chalk.dim(`(cached ${cacheAge})`)}`);
|
||||
} else {
|
||||
loadingSpinner.stop('Models loaded');
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
// For Ollama/MLX, prompt for manual entry
|
||||
@@ -148,7 +186,8 @@ async function selectModel(
|
||||
}
|
||||
|
||||
// Get recommended model for this provider
|
||||
const recommended = RECOMMENDED_MODELS[provider as keyof typeof RECOMMENDED_MODELS];
|
||||
const recommended =
|
||||
RECOMMENDED_MODELS[provider as keyof typeof RECOMMENDED_MODELS];
|
||||
|
||||
// Build options with recommended first
|
||||
const options: Array<{ value: string; label: string }> = [];
|
||||
@@ -161,9 +200,7 @@ async function selectModel(
|
||||
}
|
||||
|
||||
// Add other models (first 10, excluding recommended)
|
||||
const otherModels = models
|
||||
.filter((m) => m !== recommended)
|
||||
.slice(0, 10);
|
||||
const otherModels = models.filter((m) => m !== recommended).slice(0, 10);
|
||||
|
||||
otherModels.forEach((model) => {
|
||||
options.push({ value: model, label: model });
|
||||
@@ -379,27 +416,31 @@ export async function runSetup(): Promise<boolean> {
|
||||
setGlobalConfig(newConfig as any);
|
||||
|
||||
outro(
|
||||
`${chalk.green('✔')} Configuration saved to ~/.opencommit\n\n Run ${chalk.cyan('oco')} to generate commit messages!`
|
||||
`${chalk.green(
|
||||
'✔'
|
||||
)} Configuration saved to ~/.opencommit\n\n Run ${chalk.cyan(
|
||||
'oco'
|
||||
)} to generate commit messages!`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isFirstRun(): boolean {
|
||||
if (!getIsGlobalConfigFileExist()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Check if API key is missing for providers that need it
|
||||
const provider = config.OCO_AI_PROVIDER || OCO_AI_PROVIDER_ENUM.OPENAI;
|
||||
|
||||
if (NO_API_KEY_PROVIDERS.includes(provider as OCO_AI_PROVIDER_ENUM)) {
|
||||
if (MODEL_REQUIRED_PROVIDERS.includes(provider as OCO_AI_PROVIDER_ENUM)) {
|
||||
// For Ollama/MLX, check if model is set
|
||||
return !config.OCO_MODEL;
|
||||
}
|
||||
|
||||
if (provider === OCO_AI_PROVIDER_ENUM.TEST) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other providers, check if API key is set
|
||||
return !config.OCO_API_KEY;
|
||||
}
|
||||
@@ -417,9 +458,7 @@ export async function promptForMissingApiKey(): Promise<boolean> {
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nAPI key missing for ${provider}. Let's set it up.\n`
|
||||
)
|
||||
chalk.yellow(`\nAPI key missing for ${provider}. Let's set it up.\n`)
|
||||
);
|
||||
|
||||
const apiKey = await getApiKey(provider);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AiEngineConfig {
|
||||
maxTokensOutput: number;
|
||||
maxTokensInput: number;
|
||||
baseURL?: string;
|
||||
proxy?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import OpenAI from 'openai';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface AimlApiConfig extends AiEngineConfig {}
|
||||
@@ -32,16 +33,7 @@ export class AimlApiEngine implements AiEngine {
|
||||
const message = response.data.choices?.[0]?.message;
|
||||
return message?.content ?? null;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const apiError = error.response.data.error;
|
||||
if (apiError) throw new Error(apiError.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'aimlapi', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import AnthropicClient from '@anthropic-ai/sdk';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import {
|
||||
MessageCreateParamsNonStreaming,
|
||||
MessageParam
|
||||
} from '@anthropic-ai/sdk/resources/messages.mjs';
|
||||
import { outro } from '@clack/prompts';
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { OpenAI } from 'openai';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { ModelNotFoundError } from '../utils/errors';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
@@ -21,7 +19,15 @@ export class AnthropicEngine implements AiEngine {
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.client = new AnthropicClient({ apiKey: this.config.apiKey });
|
||||
const clientOptions: any = { apiKey: this.config.apiKey };
|
||||
|
||||
const proxy =
|
||||
config.proxy || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
if (proxy) {
|
||||
clientOptions.httpAgent = new HttpsProxyAgent(proxy);
|
||||
}
|
||||
|
||||
this.client = new AnthropicClient(clientOptions);
|
||||
}
|
||||
|
||||
public generateCommitMessage = async (
|
||||
@@ -38,9 +44,14 @@ export class AnthropicEngine implements AiEngine {
|
||||
system: systemMessage,
|
||||
messages: restMessages,
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: this.config.maxTokensOutput
|
||||
};
|
||||
|
||||
// add top_p for non-4.5 models
|
||||
if (!/claude.*-4-5/.test(params.model)) {
|
||||
params.top_p = 0.1;
|
||||
}
|
||||
|
||||
try {
|
||||
const REQUEST_TOKENS = messages
|
||||
.map((msg) => tokenCount(msg.content as string) + 4)
|
||||
@@ -59,41 +70,7 @@ export class AnthropicEngine implements AiEngine {
|
||||
let content = message;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
// Check for model not found errors
|
||||
if (err.message?.toLowerCase().includes('model') &&
|
||||
(err.message?.toLowerCase().includes('not found') ||
|
||||
err.message?.toLowerCase().includes('does not exist') ||
|
||||
err.message?.toLowerCase().includes('invalid'))) {
|
||||
throw new ModelNotFoundError(this.config.model, 'anthropic', 404);
|
||||
}
|
||||
|
||||
// Check for 404 errors
|
||||
if ('status' in (error as any) && (error as any).status === 404) {
|
||||
throw new ModelNotFoundError(this.config.model, 'anthropic', 404);
|
||||
}
|
||||
|
||||
outro(`${chalk.red('✖')} ${err?.message || err}`);
|
||||
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const anthropicAiError = error.response.data.error;
|
||||
|
||||
if (anthropicAiError?.message) outro(anthropicAiError.message);
|
||||
outro(
|
||||
'For help look into README https://github.com/di-sukharev/opencommit#setup'
|
||||
);
|
||||
}
|
||||
|
||||
// Check axios 404 errors
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
throw new ModelNotFoundError(this.config.model, 'anthropic', 404);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'anthropic', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ import {
|
||||
AzureKeyCredential,
|
||||
OpenAIClient as AzureOpenAIClient
|
||||
} from '@azure/openai';
|
||||
import { outro } from '@clack/prompts';
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { OpenAI } from 'openai';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
@@ -57,24 +55,7 @@ export class AzureEngine implements AiEngine {
|
||||
let content = message?.content;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
outro(`${chalk.red('✖')} ${this.config.model}`);
|
||||
|
||||
const err = error as Error;
|
||||
outro(`${chalk.red('✖')} ${JSON.stringify(error)}`);
|
||||
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const openAiError = error.response.data.error;
|
||||
|
||||
if (openAiError?.message) outro(openAiError.message);
|
||||
outro(
|
||||
'For help look into README https://github.com/di-sukharev/opencommit#setup'
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'azure', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { OpenAiEngine, OpenAiConfig } from './openAi';
|
||||
@@ -10,9 +10,10 @@ export interface DeepseekConfig extends OpenAiConfig {}
|
||||
export class DeepseekEngine extends OpenAiEngine {
|
||||
constructor(config: DeepseekConfig) {
|
||||
// Call OpenAIEngine constructor with forced Deepseek baseURL
|
||||
// Put baseURL first so user config can override it
|
||||
super({
|
||||
...config,
|
||||
baseURL: 'https://api.deepseek.com/v1'
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
...config
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,17 +46,7 @@ export class DeepseekEngine extends OpenAiEngine {
|
||||
let content = message?.content;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const openAiError = error.response.data.error;
|
||||
|
||||
if (openAiError) throw new Error(openAiError.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'deepseek', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
@@ -39,9 +40,8 @@ export class FlowiseEngine implements AiEngine {
|
||||
const message = response.data;
|
||||
let content = message?.text;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error ?? err.message;
|
||||
throw new Error('local model issues. details: ' + message);
|
||||
} catch (error) {
|
||||
throw normalizeEngineError(error, 'flowise', this.config.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
HarmCategory,
|
||||
Part
|
||||
} from '@google/generative-ai';
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ModelNotFoundError } from '../utils/errors';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
@@ -30,10 +29,15 @@ export class GeminiEngine implements AiEngine {
|
||||
.map((m) => m.content)
|
||||
.join('\n');
|
||||
|
||||
const gemini = this.client.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
systemInstruction
|
||||
});
|
||||
const gemini = this.client.getGenerativeModel(
|
||||
{
|
||||
model: this.config.model,
|
||||
systemInstruction
|
||||
},
|
||||
{
|
||||
baseUrl: this.config.baseURL
|
||||
}
|
||||
);
|
||||
|
||||
const contents = messages
|
||||
.filter((m) => m.role !== 'system')
|
||||
@@ -76,30 +80,7 @@ export class GeminiEngine implements AiEngine {
|
||||
const content = result.response.text();
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
// Check for model not found errors
|
||||
if (err.message?.toLowerCase().includes('model') &&
|
||||
(err.message?.toLowerCase().includes('not found') ||
|
||||
err.message?.toLowerCase().includes('does not exist') ||
|
||||
err.message?.toLowerCase().includes('invalid'))) {
|
||||
throw new ModelNotFoundError(this.config.model, 'gemini', 404);
|
||||
}
|
||||
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const geminiError = error.response.data.error;
|
||||
if (geminiError) throw new Error(geminiError?.message);
|
||||
}
|
||||
|
||||
// Check axios 404 errors
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
throw new ModelNotFoundError(this.config.model, 'gemini', 404);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'gemini', this.config.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
@@ -63,17 +64,7 @@ export class MistralAiEngine implements AiEngine {
|
||||
let content = message.content as string;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const mistralError = error.response.data.error;
|
||||
|
||||
if (mistralError) throw new Error(mistralError.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'mistral', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface MLXConfig extends AiEngineConfig {}
|
||||
|
||||
const DEFAULT_MLX_URL = 'http://localhost:8080';
|
||||
const MLX_CHAT_PATH = '/v1/chat/completions';
|
||||
|
||||
export class MLXEngine implements AiEngine {
|
||||
config: MLXConfig;
|
||||
client: AxiosInstance;
|
||||
private chatUrl: string;
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
|
||||
const baseUrl = config.baseURL || DEFAULT_MLX_URL;
|
||||
this.chatUrl = `${baseUrl}${MLX_CHAT_PATH}`;
|
||||
|
||||
this.client = axios.create({
|
||||
url: config.baseURL
|
||||
? `${config.baseURL}/${config.apiKey}`
|
||||
: 'http://localhost:8080/v1/chat/completions',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
@@ -30,18 +36,14 @@ export class MLXEngine implements AiEngine {
|
||||
stream: false
|
||||
};
|
||||
try {
|
||||
const response = await this.client.post(
|
||||
this.client.getUri(this.config),
|
||||
params
|
||||
);
|
||||
const response = await this.client.post(this.chatUrl, params);
|
||||
|
||||
const choices = response.data.choices;
|
||||
const message = choices[0].message;
|
||||
let content = message?.content;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error ?? err.message;
|
||||
throw new Error(`MLX provider error: ${message}`);
|
||||
} catch (error) {
|
||||
throw normalizeEngineError(error, 'mlx', this.config.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ModelNotFoundError } from '../utils/errors';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface OllamaConfig extends AiEngineConfig {}
|
||||
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
|
||||
const OLLAMA_CHAT_PATH = '/api/chat';
|
||||
|
||||
export class OllamaEngine implements AiEngine {
|
||||
config: OllamaConfig;
|
||||
client: AxiosInstance;
|
||||
private chatUrl: string;
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
|
||||
const baseUrl = config.baseURL || DEFAULT_OLLAMA_URL;
|
||||
this.chatUrl = `${baseUrl}${OLLAMA_CHAT_PATH}`;
|
||||
|
||||
// Combine base headers with custom headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...config.customHeaders
|
||||
};
|
||||
|
||||
this.client = axios.create({
|
||||
url: config.baseURL
|
||||
? `${config.baseURL}/${config.apiKey}`
|
||||
: 'http://localhost:11434/api/chat',
|
||||
headers
|
||||
});
|
||||
this.client = axios.create({ headers });
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
@@ -37,31 +39,13 @@ export class OllamaEngine implements AiEngine {
|
||||
stream: false
|
||||
};
|
||||
try {
|
||||
const response = await this.client.post(
|
||||
this.client.getUri(this.config),
|
||||
params
|
||||
);
|
||||
const response = await this.client.post(this.chatUrl, params);
|
||||
|
||||
const { message } = response.data;
|
||||
let content = message?.content;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error ?? err.message;
|
||||
|
||||
// Check for model not found errors
|
||||
if (message?.toLowerCase().includes('model') &&
|
||||
(message?.toLowerCase().includes('not found') ||
|
||||
message?.toLowerCase().includes('does not exist') ||
|
||||
message?.toLowerCase().includes('pull'))) {
|
||||
throw new ModelNotFoundError(this.config.model, 'ollama', 404);
|
||||
}
|
||||
|
||||
// Check for 404 status
|
||||
if (err.response?.status === 404) {
|
||||
throw new ModelNotFoundError(this.config.model, 'ollama', 404);
|
||||
}
|
||||
|
||||
throw new Error(`Ollama provider error: ${message}`);
|
||||
} catch (error) {
|
||||
throw normalizeEngineError(error, 'ollama', this.config.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { parseCustomHeaders } from '../utils/engine';
|
||||
import { ModelNotFoundError } from '../utils/errors';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
@@ -24,6 +24,12 @@ export class OpenAiEngine implements AiEngine {
|
||||
clientOptions.baseURL = config.baseURL;
|
||||
}
|
||||
|
||||
const proxy =
|
||||
config.proxy || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
if (proxy) {
|
||||
clientOptions.httpAgent = new HttpsProxyAgent(proxy);
|
||||
}
|
||||
|
||||
if (config.customHeaders) {
|
||||
const headers = parseCustomHeaders(config.customHeaders);
|
||||
if (Object.keys(headers).length > 0) {
|
||||
@@ -37,12 +43,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 params = {
|
||||
model: this.config.model,
|
||||
messages,
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: this.config.maxTokensOutput
|
||||
...(isReasoningModel
|
||||
? { max_completion_tokens: this.config.maxTokensOutput }
|
||||
: {
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: this.config.maxTokensOutput
|
||||
})
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -56,42 +68,15 @@ export class OpenAiEngine implements AiEngine {
|
||||
)
|
||||
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
|
||||
|
||||
const completion = await this.client.chat.completions.create(params);
|
||||
const completion = await this.client.chat.completions.create(
|
||||
params as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
|
||||
);
|
||||
|
||||
const message = completion.choices[0].message;
|
||||
let content = message?.content;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
// Check for model not found errors
|
||||
if (err.message?.toLowerCase().includes('model') &&
|
||||
(err.message?.toLowerCase().includes('not found') ||
|
||||
err.message?.toLowerCase().includes('does not exist') ||
|
||||
err.message?.toLowerCase().includes('invalid'))) {
|
||||
throw new ModelNotFoundError(this.config.model, 'openai', 404);
|
||||
}
|
||||
|
||||
// Check for 404 errors from API
|
||||
if ('status' in (error as any) && (error as any).status === 404) {
|
||||
throw new ModelNotFoundError(this.config.model, 'openai', 404);
|
||||
}
|
||||
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const openAiError = error.response.data.error;
|
||||
|
||||
if (openAiError) throw new Error(openAiError.message);
|
||||
}
|
||||
|
||||
// Check axios 404 errors
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
throw new ModelNotFoundError(this.config.model, 'openai', 404);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'openai', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import OpenAI from 'openai';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { normalizeEngineError } from '../utils/engineErrorHandler';
|
||||
import { removeContentTags } from '../utils/removeContentTags';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface OpenRouterConfig extends AiEngineConfig {}
|
||||
|
||||
@@ -33,17 +34,7 @@ export class OpenRouterEngine implements AiEngine {
|
||||
let content = message?.content;
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const openRouterError = error.response.data.error;
|
||||
|
||||
if (openRouterError) throw new Error(openRouterError.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
throw normalizeEngineError(error, 'openrouter', this.config.model);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ async function handleModelNotFoundError(
|
||||
provider: string,
|
||||
currentModel: string
|
||||
): Promise<string | null> {
|
||||
console.log(
|
||||
chalk.red(`\n✖ Model '${currentModel}' not found\n`)
|
||||
);
|
||||
console.log(chalk.red(`\n✖ Model '${currentModel}' not found\n`));
|
||||
|
||||
const suggestedModels = getSuggestedModels(provider, currentModel);
|
||||
const recommended =
|
||||
|
||||
@@ -47,6 +47,7 @@ export function getEngine(): AiEngine {
|
||||
maxTokensOutput: config.OCO_TOKENS_MAX_OUTPUT!,
|
||||
maxTokensInput: config.OCO_TOKENS_MAX_INPUT!,
|
||||
baseURL: config.OCO_API_URL!,
|
||||
proxy: config.OCO_PROXY!,
|
||||
apiKey: config.OCO_API_KEY!,
|
||||
customHeaders
|
||||
};
|
||||
|
||||
205
src/utils/engineErrorHandler.ts
Normal file
205
src/utils/engineErrorHandler.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
AuthenticationError,
|
||||
InsufficientCreditsError,
|
||||
ModelNotFoundError,
|
||||
RateLimitError,
|
||||
ServiceUnavailableError
|
||||
} from './errors';
|
||||
|
||||
/**
|
||||
* Extracts HTTP status code from various error types
|
||||
*/
|
||||
function getStatusCode(error: unknown): number | null {
|
||||
// Direct status property (common in API SDKs)
|
||||
if (typeof (error as any)?.status === 'number') {
|
||||
return (error as any).status;
|
||||
}
|
||||
|
||||
// Axios-style errors
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.status ?? null;
|
||||
}
|
||||
|
||||
// Response object with status
|
||||
if (typeof (error as any)?.response?.status === 'number') {
|
||||
return (error as any).response.status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts retry-after value from error headers (for rate limiting)
|
||||
*/
|
||||
function getRetryAfter(error: unknown): number | undefined {
|
||||
const headers = (error as any)?.response?.headers;
|
||||
if (headers) {
|
||||
const retryAfter = headers['retry-after'] || headers['Retry-After'];
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) {
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the error message from various error structures
|
||||
*/
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// API error response structures
|
||||
const apiError = (error as any)?.response?.data?.error;
|
||||
if (apiError) {
|
||||
if (typeof apiError === 'string') {
|
||||
return apiError;
|
||||
}
|
||||
if (apiError.message) {
|
||||
return apiError.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Direct error data
|
||||
const errorData = (error as any)?.error;
|
||||
if (errorData) {
|
||||
if (typeof errorData === 'string') {
|
||||
return errorData;
|
||||
}
|
||||
if (errorData.message) {
|
||||
return errorData.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return 'An unknown error occurred';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the error message indicates a model not found error
|
||||
*/
|
||||
function isModelNotFoundMessage(message: string): boolean {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
return (
|
||||
(lowerMessage.includes('model') &&
|
||||
(lowerMessage.includes('not found') ||
|
||||
lowerMessage.includes('does not exist') ||
|
||||
lowerMessage.includes('invalid') ||
|
||||
lowerMessage.includes('pull'))) ||
|
||||
lowerMessage.includes('does_not_exist')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the error message indicates insufficient credits
|
||||
*/
|
||||
function isInsufficientCreditsMessage(message: string): boolean {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes('insufficient') ||
|
||||
lowerMessage.includes('credit') ||
|
||||
lowerMessage.includes('quota') ||
|
||||
lowerMessage.includes('balance too low') ||
|
||||
lowerMessage.includes('billing') ||
|
||||
lowerMessage.includes('payment required') ||
|
||||
lowerMessage.includes('exceeded')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes raw API errors into typed error classes.
|
||||
* This provides consistent error handling across all engine implementations.
|
||||
*
|
||||
* @param error - The raw error from the API call
|
||||
* @param provider - The AI provider name (e.g., 'openai', 'anthropic')
|
||||
* @param model - The model being used
|
||||
* @returns A typed Error instance
|
||||
*/
|
||||
export function normalizeEngineError(
|
||||
error: unknown,
|
||||
provider: string,
|
||||
model: string
|
||||
): Error {
|
||||
// If it's already one of our custom errors, return as-is
|
||||
if (
|
||||
error instanceof ModelNotFoundError ||
|
||||
error instanceof AuthenticationError ||
|
||||
error instanceof InsufficientCreditsError ||
|
||||
error instanceof RateLimitError ||
|
||||
error instanceof ServiceUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const statusCode = getStatusCode(error);
|
||||
const message = extractErrorMessage(error);
|
||||
|
||||
// Handle based on HTTP status codes
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
return new AuthenticationError(provider, message);
|
||||
|
||||
case 402:
|
||||
return new InsufficientCreditsError(provider, message);
|
||||
|
||||
case 404:
|
||||
// Could be model not found or endpoint not found
|
||||
if (isModelNotFoundMessage(message)) {
|
||||
return new ModelNotFoundError(model, provider, 404);
|
||||
}
|
||||
// Return generic error for other 404s
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
|
||||
case 429:
|
||||
const retryAfter = getRetryAfter(error);
|
||||
return new RateLimitError(provider, retryAfter, message);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new ServiceUnavailableError(provider, statusCode, message);
|
||||
}
|
||||
|
||||
// Handle based on error message content
|
||||
if (isModelNotFoundMessage(message)) {
|
||||
return new ModelNotFoundError(model, provider, 404);
|
||||
}
|
||||
|
||||
if (isInsufficientCreditsMessage(message)) {
|
||||
return new InsufficientCreditsError(provider, message);
|
||||
}
|
||||
|
||||
// Check for rate limit patterns in message
|
||||
const lowerMessage = message.toLowerCase();
|
||||
if (
|
||||
lowerMessage.includes('rate limit') ||
|
||||
lowerMessage.includes('rate_limit') ||
|
||||
lowerMessage.includes('too many requests')
|
||||
) {
|
||||
return new RateLimitError(provider, undefined, message);
|
||||
}
|
||||
|
||||
// Check for auth patterns in message
|
||||
if (
|
||||
lowerMessage.includes('unauthorized') ||
|
||||
lowerMessage.includes('api key') ||
|
||||
lowerMessage.includes('apikey') ||
|
||||
lowerMessage.includes('authentication') ||
|
||||
lowerMessage.includes('invalid_api_key')
|
||||
) {
|
||||
return new AuthenticationError(provider, message);
|
||||
}
|
||||
|
||||
// Return original error or wrap in Error if needed
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
@@ -1,5 +1,76 @@
|
||||
import chalk from 'chalk';
|
||||
import { MODEL_LIST, OCO_AI_PROVIDER_ENUM } from '../commands/config';
|
||||
|
||||
// Provider billing/help URLs for common errors
|
||||
export const PROVIDER_BILLING_URLS: Record<string, string | null> = {
|
||||
[OCO_AI_PROVIDER_ENUM.ANTHROPIC]:
|
||||
'https://console.anthropic.com/settings/billing',
|
||||
[OCO_AI_PROVIDER_ENUM.OPENAI]:
|
||||
'https://platform.openai.com/settings/organization/billing',
|
||||
[OCO_AI_PROVIDER_ENUM.GEMINI]: 'https://aistudio.google.com/app/plan',
|
||||
[OCO_AI_PROVIDER_ENUM.GROQ]: 'https://console.groq.com/settings/billing',
|
||||
[OCO_AI_PROVIDER_ENUM.MISTRAL]: 'https://console.mistral.ai/billing/',
|
||||
[OCO_AI_PROVIDER_ENUM.DEEPSEEK]: 'https://platform.deepseek.com/usage',
|
||||
[OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'https://openrouter.ai/credits',
|
||||
[OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'https://aimlapi.com/app/billing',
|
||||
[OCO_AI_PROVIDER_ENUM.AZURE]:
|
||||
'https://portal.azure.com/#view/Microsoft_Azure_CostManagement',
|
||||
[OCO_AI_PROVIDER_ENUM.OLLAMA]: null,
|
||||
[OCO_AI_PROVIDER_ENUM.MLX]: null,
|
||||
[OCO_AI_PROVIDER_ENUM.FLOWISE]: null,
|
||||
[OCO_AI_PROVIDER_ENUM.TEST]: null
|
||||
};
|
||||
|
||||
// Error type for insufficient credits/quota
|
||||
export class InsufficientCreditsError extends Error {
|
||||
public readonly provider: string;
|
||||
|
||||
constructor(provider: string, message?: string) {
|
||||
super(
|
||||
message || `Insufficient credits or quota for provider '${provider}'`
|
||||
);
|
||||
this.name = 'InsufficientCreditsError';
|
||||
this.provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
// Error type for rate limiting (429 errors)
|
||||
export class RateLimitError extends Error {
|
||||
public readonly provider: string;
|
||||
public readonly retryAfter?: number;
|
||||
|
||||
constructor(provider: string, retryAfter?: number, message?: string) {
|
||||
super(message || `Rate limit exceeded for provider '${provider}'`);
|
||||
this.name = 'RateLimitError';
|
||||
this.provider = provider;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
// Error type for service unavailable (5xx errors)
|
||||
export class ServiceUnavailableError extends Error {
|
||||
public readonly provider: string;
|
||||
public readonly statusCode: number;
|
||||
|
||||
constructor(provider: string, statusCode: number = 503, message?: string) {
|
||||
super(message || `Service unavailable for provider '${provider}'`);
|
||||
this.name = 'ServiceUnavailableError';
|
||||
this.provider = provider;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Error type for authentication failures
|
||||
export class AuthenticationError extends Error {
|
||||
public readonly provider: string;
|
||||
|
||||
constructor(provider: string, message?: string) {
|
||||
super(message || `Authentication failed for provider '${provider}'`);
|
||||
this.name = 'AuthenticationError';
|
||||
this.provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelNotFoundError extends Error {
|
||||
public readonly modelName: string;
|
||||
public readonly provider: string;
|
||||
@@ -164,3 +235,247 @@ export function formatErrorWithRecovery(
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// Detect insufficient credits/quota errors from various providers
|
||||
export function isInsufficientCreditsError(error: unknown): boolean {
|
||||
if (error instanceof InsufficientCreditsError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Common patterns for insufficient credits/quota
|
||||
if (
|
||||
message.includes('insufficient') ||
|
||||
message.includes('credit') ||
|
||||
message.includes('quota') ||
|
||||
message.includes('balance') ||
|
||||
message.includes('billing') ||
|
||||
message.includes('payment') ||
|
||||
message.includes('exceeded') ||
|
||||
message.includes('limit reached') ||
|
||||
message.includes('no remaining')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for 402 Payment Required status
|
||||
if ('status' in (error as any) && (error as any).status === 402) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ('response' in (error as any)) {
|
||||
const response = (error as any).response;
|
||||
if (response?.status === 402) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect rate limit errors (429)
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
if (error instanceof RateLimitError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Common patterns for rate limiting
|
||||
if (
|
||||
message.includes('rate limit') ||
|
||||
message.includes('rate_limit') ||
|
||||
message.includes('too many requests') ||
|
||||
message.includes('throttle')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for 429 status
|
||||
if ('status' in (error as any) && (error as any).status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ('response' in (error as any)) {
|
||||
const response = (error as any).response;
|
||||
if (response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect service unavailable errors (5xx)
|
||||
export function isServiceUnavailableError(error: unknown): boolean {
|
||||
if (error instanceof ServiceUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Common patterns for service unavailable
|
||||
if (
|
||||
message.includes('service unavailable') ||
|
||||
message.includes('server error') ||
|
||||
message.includes('internal error') ||
|
||||
message.includes('temporarily unavailable') ||
|
||||
message.includes('overloaded')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for 5xx status
|
||||
const status = (error as any).status || (error as any).response?.status;
|
||||
if (status && status >= 500 && status < 600) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// User-friendly formatted error structure
|
||||
export interface FormattedError {
|
||||
title: string;
|
||||
message: string;
|
||||
helpUrl: string | null;
|
||||
suggestion: string | null;
|
||||
}
|
||||
|
||||
// Format an error into a user-friendly structure
|
||||
export function formatUserFriendlyError(
|
||||
error: unknown,
|
||||
provider: string
|
||||
): FormattedError {
|
||||
const billingUrl = PROVIDER_BILLING_URLS[provider] || null;
|
||||
|
||||
// Handle our custom error types first
|
||||
if (error instanceof InsufficientCreditsError) {
|
||||
return {
|
||||
title: 'Insufficient Credits',
|
||||
message: `Your ${provider} account has insufficient credits or quota.`,
|
||||
helpUrl: billingUrl,
|
||||
suggestion: 'Add credits to your account to continue using the service.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof RateLimitError) {
|
||||
const retryMsg = error.retryAfter
|
||||
? `Please wait ${error.retryAfter} seconds before retrying.`
|
||||
: 'Please wait a moment before retrying.';
|
||||
return {
|
||||
title: 'Rate Limit Exceeded',
|
||||
message: `You've made too many requests to ${provider}.`,
|
||||
helpUrl: billingUrl,
|
||||
suggestion: retryMsg
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof ServiceUnavailableError) {
|
||||
return {
|
||||
title: 'Service Unavailable',
|
||||
message: `The ${provider} service is temporarily unavailable.`,
|
||||
helpUrl: null,
|
||||
suggestion: 'Please try again in a few moments.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof AuthenticationError) {
|
||||
return {
|
||||
title: 'Authentication Failed',
|
||||
message: `Your ${provider} API key is invalid or expired.`,
|
||||
helpUrl: billingUrl,
|
||||
suggestion: 'Run `oco setup` to configure a valid API key.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof ModelNotFoundError) {
|
||||
return {
|
||||
title: 'Model Not Found',
|
||||
message: `The model '${error.modelName}' is not available for ${provider}.`,
|
||||
helpUrl: null,
|
||||
suggestion: 'Run `oco setup` to select a valid model.'
|
||||
};
|
||||
}
|
||||
|
||||
// Detect error type from raw errors
|
||||
if (isInsufficientCreditsError(error)) {
|
||||
return {
|
||||
title: 'Insufficient Credits',
|
||||
message: `Your ${provider} account has insufficient credits or quota.`,
|
||||
helpUrl: billingUrl,
|
||||
suggestion: 'Add credits to your account to continue using the service.'
|
||||
};
|
||||
}
|
||||
|
||||
if (isRateLimitError(error)) {
|
||||
return {
|
||||
title: 'Rate Limit Exceeded',
|
||||
message: `You've made too many requests to ${provider}.`,
|
||||
helpUrl: billingUrl,
|
||||
suggestion: 'Please wait a moment before retrying.'
|
||||
};
|
||||
}
|
||||
|
||||
if (isServiceUnavailableError(error)) {
|
||||
return {
|
||||
title: 'Service Unavailable',
|
||||
message: `The ${provider} service is temporarily unavailable.`,
|
||||
helpUrl: null,
|
||||
suggestion: 'Please try again in a few moments.'
|
||||
};
|
||||
}
|
||||
|
||||
if (isApiKeyError(error)) {
|
||||
return {
|
||||
title: 'Authentication Failed',
|
||||
message: `Your ${provider} API key is invalid or expired.`,
|
||||
helpUrl: billingUrl,
|
||||
suggestion: 'Run `oco setup` to configure a valid API key.'
|
||||
};
|
||||
}
|
||||
|
||||
if (isModelNotFoundError(error)) {
|
||||
const model = (error as any).modelName || (error as any).model || 'unknown';
|
||||
return {
|
||||
title: 'Model Not Found',
|
||||
message: `The model '${model}' is not available for ${provider}.`,
|
||||
helpUrl: null,
|
||||
suggestion: 'Run `oco setup` to select a valid model.'
|
||||
};
|
||||
}
|
||||
|
||||
// Default: generic error
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
title: 'Error',
|
||||
message: errorMessage,
|
||||
helpUrl: null,
|
||||
suggestion: 'Run `oco setup` to reconfigure or check your settings.'
|
||||
};
|
||||
}
|
||||
|
||||
// Print a formatted error as a chalk-styled string
|
||||
export function printFormattedError(formatted: FormattedError): string {
|
||||
let output = `\n${chalk.red('✖')} ${chalk.bold.red(formatted.title)}\n`;
|
||||
output += ` ${formatted.message}\n`;
|
||||
|
||||
if (formatted.helpUrl) {
|
||||
output += `\n ${chalk.cyan('Help:')} ${chalk.underline(
|
||||
formatted.helpUrl
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
if (formatted.suggestion) {
|
||||
output += `\n ${chalk.yellow('Suggestion:')} ${formatted.suggestion}\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -87,15 +87,132 @@ export async function fetchOllamaModels(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAnthropicModels(apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/models', {
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return MODEL_LIST.anthropic;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = data.data
|
||||
?.map((m: { id: string }) => m.id)
|
||||
.filter((id: string) => id.startsWith('claude-'))
|
||||
.sort();
|
||||
|
||||
return models && models.length > 0 ? models : MODEL_LIST.anthropic;
|
||||
} catch {
|
||||
return MODEL_LIST.anthropic;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMistralModels(apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('https://api.mistral.ai/v1/models', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return MODEL_LIST.mistral;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = data.data?.map((m: { id: string }) => m.id).sort();
|
||||
|
||||
return models && models.length > 0 ? models : MODEL_LIST.mistral;
|
||||
} catch {
|
||||
return MODEL_LIST.mistral;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGroqModels(apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('https://api.groq.com/openai/v1/models', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return MODEL_LIST.groq;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = data.data?.map((m: { id: string }) => m.id).sort();
|
||||
|
||||
return models && models.length > 0 ? models : MODEL_LIST.groq;
|
||||
} catch {
|
||||
return MODEL_LIST.groq;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOpenRouterModels(apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return MODEL_LIST.openrouter;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Filter to text-capable models only (exclude image/audio models)
|
||||
const models = data.data
|
||||
?.filter(
|
||||
(m: { id: string; context_length?: number }) =>
|
||||
m.context_length && m.context_length > 0
|
||||
)
|
||||
.map((m: { id: string }) => m.id)
|
||||
.sort();
|
||||
|
||||
return models && models.length > 0 ? models : MODEL_LIST.openrouter;
|
||||
} catch {
|
||||
return MODEL_LIST.openrouter;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDeepSeekModels(apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('https://api.deepseek.com/v1/models', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return MODEL_LIST.deepseek;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = data.data?.map((m: { id: string }) => m.id).sort();
|
||||
|
||||
return models && models.length > 0 ? models : MODEL_LIST.deepseek;
|
||||
} catch {
|
||||
return MODEL_LIST.deepseek;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModelsForProvider(
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string
|
||||
baseUrl?: string,
|
||||
forceRefresh: boolean = false
|
||||
): Promise<string[]> {
|
||||
const cache = readCache();
|
||||
|
||||
// Return cached models if valid
|
||||
if (isCacheValid(cache) && cache!.models[provider]) {
|
||||
// Return cached models if valid (unless force refresh)
|
||||
if (!forceRefresh && isCacheValid(cache) && cache!.models[provider]) {
|
||||
return cache!.models[provider];
|
||||
}
|
||||
|
||||
@@ -115,23 +232,40 @@ export async function fetchModelsForProvider(
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.ANTHROPIC:
|
||||
models = MODEL_LIST.anthropic;
|
||||
if (apiKey) {
|
||||
models = await fetchAnthropicModels(apiKey);
|
||||
} else {
|
||||
models = MODEL_LIST.anthropic;
|
||||
}
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.GEMINI:
|
||||
// Google's API doesn't easily list generative models, use hardcoded list
|
||||
models = MODEL_LIST.gemini;
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.GROQ:
|
||||
models = MODEL_LIST.groq;
|
||||
if (apiKey) {
|
||||
models = await fetchGroqModels(apiKey);
|
||||
} else {
|
||||
models = MODEL_LIST.groq;
|
||||
}
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.MISTRAL:
|
||||
models = MODEL_LIST.mistral;
|
||||
if (apiKey) {
|
||||
models = await fetchMistralModels(apiKey);
|
||||
} else {
|
||||
models = MODEL_LIST.mistral;
|
||||
}
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.DEEPSEEK:
|
||||
models = MODEL_LIST.deepseek;
|
||||
if (apiKey) {
|
||||
models = await fetchDeepSeekModels(apiKey);
|
||||
} else {
|
||||
models = MODEL_LIST.deepseek;
|
||||
}
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.AIMLAPI:
|
||||
@@ -139,7 +273,11 @@ export async function fetchModelsForProvider(
|
||||
break;
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.OPENROUTER:
|
||||
models = MODEL_LIST.openrouter;
|
||||
if (apiKey) {
|
||||
models = await fetchOpenRouterModels(apiKey);
|
||||
} else {
|
||||
models = MODEL_LIST.openrouter;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -168,3 +306,25 @@ export function clearModelCache(): void {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
export function getCacheInfo(): {
|
||||
timestamp: number | null;
|
||||
providers: string[];
|
||||
} {
|
||||
const cache = readCache();
|
||||
if (!cache) {
|
||||
return { timestamp: null, providers: [] };
|
||||
}
|
||||
return {
|
||||
timestamp: cache.timestamp,
|
||||
providers: Object.keys(cache.models || {})
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedModels(provider: string): string[] | null {
|
||||
const cache = readCache();
|
||||
if (!cache || !cache.models[provider]) {
|
||||
return null;
|
||||
}
|
||||
return cache.models[provider];
|
||||
}
|
||||
|
||||
21
src/utils/proxy.ts
Normal file
21
src/utils/proxy.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { setGlobalDispatcher, ProxyAgent } from 'undici';
|
||||
import axios from 'axios';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
export function setupProxy(proxyUrl?: string) {
|
||||
const proxy = proxyUrl || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
if (proxy) {
|
||||
try {
|
||||
// Set global dispatcher for undici (affects globalThis.fetch used by Gemini and others)
|
||||
const dispatcher = new ProxyAgent(proxy);
|
||||
setGlobalDispatcher(dispatcher);
|
||||
|
||||
// Set axios global agent
|
||||
const agent = new HttpsProxyAgent(proxy);
|
||||
axios.defaults.httpsAgent = agent;
|
||||
axios.defaults.proxy = false; // Disable axios built-in proxy handling to use agent
|
||||
} catch (error) {
|
||||
console.warn(`[Proxy Error] Failed to set proxy: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import { rm } from 'fs';
|
||||
const fsExec = promisify(exec);
|
||||
const fsRemove = promisify(rm);
|
||||
|
||||
const waitForCommitConfirmation = async (findByText: any) => {
|
||||
expect(await findByText('Generating the commit message')).toBeInTheConsole();
|
||||
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
|
||||
};
|
||||
|
||||
/**
|
||||
* git remote -v
|
||||
*
|
||||
@@ -97,7 +102,7 @@ describe('cli flow to push git branch', () => {
|
||||
[resolve('./out/cli.cjs')],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
|
||||
await waitForCommitConfirmation(findByText);
|
||||
userEvent.keyboard('[Enter]');
|
||||
|
||||
expect(
|
||||
@@ -129,7 +134,7 @@ describe('cli flow to push git branch', () => {
|
||||
[resolve('./out/cli.cjs')],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
|
||||
await waitForCommitConfirmation(findByText);
|
||||
userEvent.keyboard('[Enter]');
|
||||
|
||||
expect(
|
||||
@@ -162,7 +167,7 @@ describe('cli flow to push git branch', () => {
|
||||
[resolve('./out/cli.cjs')],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
|
||||
await waitForCommitConfirmation(findByText);
|
||||
userEvent.keyboard('[Enter]');
|
||||
|
||||
expect(
|
||||
@@ -190,7 +195,7 @@ describe('cli flow to push git branch', () => {
|
||||
[resolve('./out/cli.cjs')],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
|
||||
await waitForCommitConfirmation(findByText);
|
||||
userEvent.keyboard('[Enter]');
|
||||
|
||||
expect(await findByText('Choose a remote to push to')).toBeInTheConsole();
|
||||
|
||||
@@ -3,6 +3,10 @@ import { render } from 'cli-testing-library';
|
||||
import 'cli-testing-library/extend-expect';
|
||||
import { prepareEnvironment, wait } from '../utils';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function getAbsolutePath(relativePath: string) {
|
||||
// Use process.cwd() which should be the project root during test execution
|
||||
@@ -27,9 +31,10 @@ async function setupCommitlint(dir: string, ver: 9 | 18 | 19) {
|
||||
configPath = getAbsolutePath('./data/commitlint_19/commitlint.config.js');
|
||||
break;
|
||||
}
|
||||
await render('cp', ['-r', packagePath, '.'], { cwd: dir });
|
||||
await render('cp', [packageJsonPath, '.'], { cwd: dir });
|
||||
await render('cp', [configPath, '.'], { cwd: dir });
|
||||
|
||||
await execFileAsync('cp', ['-R', packagePath, path.join(dir, 'node_modules')]);
|
||||
await execFileAsync('cp', [packageJsonPath, path.join(dir, 'package.json')]);
|
||||
await execFileAsync('cp', [configPath, path.join(dir, 'commitlint.config.js')]);
|
||||
await wait(3000); // Avoid flakiness by waiting
|
||||
}
|
||||
|
||||
|
||||
26
test/unit/openAi.test.ts
Normal file
26
test/unit/openAi.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Test the reasoning model detection regex used in OpenAiEngine.
|
||||
// Integration test with the engine is not possible because mistral.ts
|
||||
// uses require() which is unavailable in the ESM test environment.
|
||||
const REASONING_MODEL_RE = /^(o[1-9]|gpt-5)/;
|
||||
|
||||
describe('OpenAiEngine reasoning model detection', () => {
|
||||
it.each([
|
||||
['o1', true],
|
||||
['o1-preview', true],
|
||||
['o1-mini', true],
|
||||
['o3', true],
|
||||
['o3-mini', true],
|
||||
['o4-mini', true],
|
||||
['gpt-5', true],
|
||||
['gpt-5-nano', true],
|
||||
['gpt-4o', false],
|
||||
['gpt-4o-mini', false],
|
||||
['gpt-4', false],
|
||||
['gpt-3.5-turbo', false]
|
||||
])(
|
||||
'model "%s" isReasoning=%s',
|
||||
(model, expected) => {
|
||||
expect(REASONING_MODEL_RE.test(model)).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user