can-i-merge 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/cli/index.js +56 -2
- package/dist/config/credentialStore.js +124 -0
- package/dist/config/index.js +4 -0
- package/dist/provider-custom/customProvider.js +38 -0
- package/dist/provider-custom/index.js +6 -0
- package/dist/provider-gemini/geminiProvider.js +33 -0
- package/dist/provider-gemini/index.js +6 -0
- package/dist/provider-nvidia/index.js +1 -0
- package/dist/provider-nvidia/nvidiaProvider.js +33 -0
- package/dist/provider-ollama/index.js +6 -0
- package/dist/provider-ollama/ollamaProvider.js +34 -0
- package/dist/provider-openai/index.js +6 -0
- package/dist/provider-openai/openAIProvider.js +32 -0
- package/dist/provider-openrouter/index.js +6 -0
- package/dist/provider-openrouter/openRouterProvider.js +33 -0
- package/dist/shared/openaiCompatibleProvider.js +125 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -47,9 +47,11 @@ The **Context Engine** is the real product.
|
|
|
47
47
|
- OpenAI
|
|
48
48
|
- NVIDIA
|
|
49
49
|
- Gemini
|
|
50
|
-
- Ollama
|
|
50
|
+
- Ollama (local LLM)
|
|
51
51
|
- OpenRouter
|
|
52
|
+
- Custom (any OpenAI-compatible endpoint, including self-hosted/local LLM servers)
|
|
52
53
|
- Provider abstraction
|
|
54
|
+
- Encrypted local credential storage (`--api-key`/`--model`/`--base-url`, saved for future runs)
|
|
53
55
|
- GitHub Action (Planned)
|
|
54
56
|
- VSCode Extension (Planned)
|
|
55
57
|
- MCP Server (Planned)
|
|
@@ -112,6 +114,26 @@ Choose AI provider
|
|
|
112
114
|
can-i-merge --provider claude
|
|
113
115
|
```
|
|
114
116
|
|
|
117
|
+
Use a local LLM (e.g. Ollama)
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
can-i-merge --provider ollama --model llama3.1
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Point at any custom OpenAI-compatible endpoint (self-hosted, local server, etc.)
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
can-i-merge --provider custom --base-url http://localhost:1234/v1 --model local-model
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
API key / model / base URL are saved encrypted under `~/.can-i-merge` after the first run, so later runs don't need the flags again:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
can-i-merge --provider openai --api-key sk-... --model gpt-4.1 # saved
|
|
133
|
+
can-i-merge --provider openai # reuses the saved key/model
|
|
134
|
+
can-i-merge --provider openai --forget-credentials # removes them
|
|
135
|
+
```
|
|
136
|
+
|
|
115
137
|
Security review
|
|
116
138
|
|
|
117
139
|
```bash
|
|
@@ -200,6 +222,9 @@ Move authorization check before JWT validation.
|
|
|
200
222
|
│ NVIDIA │
|
|
201
223
|
│ Gemini │
|
|
202
224
|
│ Ollama │
|
|
225
|
+
│ OpenRouter │
|
|
226
|
+
│ Custom (any OpenAI- │
|
|
227
|
+
│ compatible endpoint) │
|
|
203
228
|
└──────────────────────────┘
|
|
204
229
|
│
|
|
205
230
|
▼
|
package/dist/cli/index.js
CHANGED
|
@@ -10,10 +10,17 @@ import { readFileSync } from "node:fs";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { Command } from "commander";
|
|
13
|
+
import { clearProviderConfig, loadProviderConfig, saveProviderConfig } from "../config/index.js";
|
|
13
14
|
import { NoChangesError, runReview } from "../core/index.js";
|
|
14
15
|
import { isGitRepository } from "../git/index.js";
|
|
15
16
|
import { ProviderRegistry } from "../provider/index.js";
|
|
16
17
|
import { ClaudeProvider } from "../provider-anthropic/index.js";
|
|
18
|
+
import { CustomProvider } from "../provider-custom/index.js";
|
|
19
|
+
import { GeminiProvider } from "../provider-gemini/index.js";
|
|
20
|
+
import { NvidiaProvider } from "../provider-nvidia/index.js";
|
|
21
|
+
import { OllamaProvider } from "../provider-ollama/index.js";
|
|
22
|
+
import { OpenAIProvider } from "../provider-openai/index.js";
|
|
23
|
+
import { OpenRouterProvider } from "../provider-openrouter/index.js";
|
|
17
24
|
import { isMergeReady, reportConsole, reportJson } from "../reporter/index.js";
|
|
18
25
|
import { colors } from "../shared/colors.js";
|
|
19
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -31,6 +38,19 @@ const STAGE_MESSAGES = {
|
|
|
31
38
|
context: "Building Context...",
|
|
32
39
|
reviewing: "Reviewing...",
|
|
33
40
|
};
|
|
41
|
+
const PROVIDER_ENV_VARS = {
|
|
42
|
+
claude: { apiKey: "ANTHROPIC_API_KEY", model: "ANTHROPIC_MODEL" },
|
|
43
|
+
openai: { apiKey: "OPENAI_API_KEY", model: "OPENAI_MODEL", baseURL: "OPENAI_BASE_URL" },
|
|
44
|
+
nvidia: { apiKey: "NVIDIA_API_KEY", model: "NVIDIA_MODEL", baseURL: "NVIDIA_BASE_URL" },
|
|
45
|
+
ollama: { apiKey: "OLLAMA_API_KEY", model: "OLLAMA_MODEL", baseURL: "OLLAMA_BASE_URL" },
|
|
46
|
+
gemini: { apiKey: "GEMINI_API_KEY", model: "GEMINI_MODEL", baseURL: "GEMINI_BASE_URL" },
|
|
47
|
+
openrouter: {
|
|
48
|
+
apiKey: "OPENROUTER_API_KEY",
|
|
49
|
+
model: "OPENROUTER_MODEL",
|
|
50
|
+
baseURL: "OPENROUTER_BASE_URL",
|
|
51
|
+
},
|
|
52
|
+
custom: { apiKey: "CUSTOM_API_KEY", model: "CUSTOM_MODEL", baseURL: "CUSTOM_BASE_URL" },
|
|
53
|
+
};
|
|
34
54
|
function isReviewLevel(value) {
|
|
35
55
|
return REVIEW_LEVELS.includes(value);
|
|
36
56
|
}
|
|
@@ -40,6 +60,12 @@ function isReviewType(value) {
|
|
|
40
60
|
function buildProviderRegistry() {
|
|
41
61
|
const registry = new ProviderRegistry();
|
|
42
62
|
registry.register("claude", (config) => new ClaudeProvider(config));
|
|
63
|
+
registry.register("openai", (config) => new OpenAIProvider(config));
|
|
64
|
+
registry.register("nvidia", (config) => new NvidiaProvider(config));
|
|
65
|
+
registry.register("ollama", (config) => new OllamaProvider(config));
|
|
66
|
+
registry.register("gemini", (config) => new GeminiProvider(config));
|
|
67
|
+
registry.register("openrouter", (config) => new OpenRouterProvider(config));
|
|
68
|
+
registry.register("custom", (config) => new CustomProvider(config));
|
|
43
69
|
return registry;
|
|
44
70
|
}
|
|
45
71
|
export async function main(argv = process.argv) {
|
|
@@ -49,13 +75,24 @@ export async function main(argv = process.argv) {
|
|
|
49
75
|
.description("AI-powered Git Review Pipeline with Intelligent Context Building")
|
|
50
76
|
.version(pkg.version)
|
|
51
77
|
.option("--commit <ref>", "review a specific commit instead of the staged index")
|
|
52
|
-
.option("--provider <name>", "AI provider to use
|
|
78
|
+
.option("--provider <name>", "AI provider to use: claude, openai, nvidia, ollama, gemini, openrouter, or custom " +
|
|
79
|
+
"(any OpenAI-compatible endpoint, including local LLM servers)", "claude")
|
|
80
|
+
.option("--api-key <key>", "API key for the selected provider (saved encrypted for future runs)")
|
|
81
|
+
.option("--model <model>", "model name for the selected provider (saved for future runs)")
|
|
82
|
+
.option("--base-url <url>", "API base URL for the selected provider, e.g. a local LLM server or other " +
|
|
83
|
+
"OpenAI-compatible endpoint (saved for future runs; required for --provider custom)")
|
|
84
|
+
.option("--forget-credentials", "remove stored credentials for the selected provider and exit", false)
|
|
53
85
|
.option("--level <level>", "review level: fast, normal, or deep", "normal")
|
|
54
86
|
.option("--type <type>", "review focus: general, security, performance, architecture, or style", "general")
|
|
55
87
|
.option("--json", "output the review result as JSON", false)
|
|
56
88
|
.option("--fix", "automatically apply suggested fixes (not yet supported)", false);
|
|
57
89
|
program.parse(argv);
|
|
58
90
|
const opts = program.opts();
|
|
91
|
+
if (opts.forgetCredentials) {
|
|
92
|
+
clearProviderConfig(opts.provider);
|
|
93
|
+
console.log(colors.green(`Removed stored credentials for provider "${opts.provider}".`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
59
96
|
if (opts.fix) {
|
|
60
97
|
console.error(colors.yellow("--fix is not supported yet (see TOBE.md roadmap, Phase 4)."));
|
|
61
98
|
process.exitCode = 2;
|
|
@@ -77,10 +114,27 @@ export async function main(argv = process.argv) {
|
|
|
77
114
|
process.exitCode = 2;
|
|
78
115
|
return;
|
|
79
116
|
}
|
|
117
|
+
if (opts.apiKey !== undefined || opts.model !== undefined || opts.baseUrl !== undefined) {
|
|
118
|
+
saveProviderConfig(opts.provider, {
|
|
119
|
+
apiKey: opts.apiKey,
|
|
120
|
+
model: opts.model,
|
|
121
|
+
baseURL: opts.baseUrl,
|
|
122
|
+
});
|
|
123
|
+
if (!opts.json) {
|
|
124
|
+
console.log(colors.dim(`Saved ${opts.provider} credentials to ~/.can-i-merge (encrypted).`));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const stored = loadProviderConfig(opts.provider);
|
|
128
|
+
const envVars = PROVIDER_ENV_VARS[opts.provider];
|
|
129
|
+
const apiKey = opts.apiKey ?? (envVars && process.env[envVars.apiKey]) ?? stored.apiKey;
|
|
130
|
+
const model = opts.model ?? (envVars && process.env[envVars.model]) ?? stored.model;
|
|
131
|
+
const baseURL = opts.baseUrl ??
|
|
132
|
+
(envVars?.baseURL && process.env[envVars.baseURL]) ??
|
|
133
|
+
stored.baseURL;
|
|
80
134
|
const registry = buildProviderRegistry();
|
|
81
135
|
let provider;
|
|
82
136
|
try {
|
|
83
|
-
provider = registry.create(opts.provider);
|
|
137
|
+
provider = registry.create(opts.provider, { apiKey, model, baseURL });
|
|
84
138
|
}
|
|
85
139
|
catch (err) {
|
|
86
140
|
console.error(colors.red(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local, encrypted credential store for provider API keys.
|
|
3
|
+
*
|
|
4
|
+
* CLI flags and env vars are per-invocation. This store lets a user pass
|
|
5
|
+
* --api-key/--model once and have it picked up automatically on every later
|
|
6
|
+
* run, without re-exporting an env var each time.
|
|
7
|
+
*
|
|
8
|
+
* The encryption key is a random 32-byte value generated on first use and
|
|
9
|
+
* kept in a file (key) separate from the ciphertext (credentials.json),
|
|
10
|
+
* both under the user's home directory with owner-only permissions. This
|
|
11
|
+
* guards against accidental exposure (e.g. a backup or sync tool scooping up
|
|
12
|
+
* one file but not the other) - it does not protect against an attacker who
|
|
13
|
+
* already has read access to the user's home directory, since the key lives
|
|
14
|
+
* on the same machine as the ciphertext.
|
|
15
|
+
*/
|
|
16
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
const CONFIG_DIR = path.join(homedir(), ".can-i-merge");
|
|
21
|
+
const KEY_FILE = path.join(CONFIG_DIR, "key");
|
|
22
|
+
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
23
|
+
const ALGORITHM = "aes-256-gcm";
|
|
24
|
+
function ensureConfigDir() {
|
|
25
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
26
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function getOrCreateKey() {
|
|
30
|
+
ensureConfigDir();
|
|
31
|
+
if (existsSync(KEY_FILE)) {
|
|
32
|
+
return Buffer.from(readFileSync(KEY_FILE, "utf8"), "base64");
|
|
33
|
+
}
|
|
34
|
+
const key = randomBytes(32);
|
|
35
|
+
writeFileSync(KEY_FILE, key.toString("base64"), { mode: 0o600 });
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
function encrypt(plaintext, key) {
|
|
39
|
+
const iv = randomBytes(12);
|
|
40
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
41
|
+
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
42
|
+
return {
|
|
43
|
+
iv: iv.toString("base64"),
|
|
44
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
45
|
+
data: data.toString("base64"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function decrypt(value, key) {
|
|
49
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(value.iv, "base64"));
|
|
50
|
+
decipher.setAuthTag(Buffer.from(value.tag, "base64"));
|
|
51
|
+
const data = Buffer.concat([
|
|
52
|
+
decipher.update(Buffer.from(value.data, "base64")),
|
|
53
|
+
decipher.final(),
|
|
54
|
+
]);
|
|
55
|
+
return data.toString("utf8");
|
|
56
|
+
}
|
|
57
|
+
function readCredentialsFile() {
|
|
58
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function writeCredentialsFile(contents) {
|
|
69
|
+
ensureConfigDir();
|
|
70
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(contents, null, 2), { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
/** Persist an API key, model, and/or base URL for a provider, encrypting the API key at rest. */
|
|
73
|
+
export function saveProviderConfig(provider, config) {
|
|
74
|
+
const file = readCredentialsFile();
|
|
75
|
+
const next = { ...file[provider] };
|
|
76
|
+
if (config.apiKey !== undefined) {
|
|
77
|
+
next.apiKey = encrypt(config.apiKey, getOrCreateKey());
|
|
78
|
+
}
|
|
79
|
+
if (config.model !== undefined) {
|
|
80
|
+
next.model = config.model;
|
|
81
|
+
}
|
|
82
|
+
if (config.baseURL !== undefined) {
|
|
83
|
+
next.baseURL = config.baseURL;
|
|
84
|
+
}
|
|
85
|
+
file[provider] = next;
|
|
86
|
+
writeCredentialsFile(file);
|
|
87
|
+
}
|
|
88
|
+
/** Load a previously persisted API key/model/base URL for a provider, decrypting the API key. */
|
|
89
|
+
export function loadProviderConfig(provider) {
|
|
90
|
+
const stored = readCredentialsFile()[provider];
|
|
91
|
+
if (!stored) {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
const result = {};
|
|
95
|
+
if (stored.apiKey) {
|
|
96
|
+
try {
|
|
97
|
+
result.apiKey = decrypt(stored.apiKey, getOrCreateKey());
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Key file missing/rotated or ciphertext corrupted - treat as absent
|
|
101
|
+
// rather than crashing the CLI.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (stored.model) {
|
|
105
|
+
result.model = stored.model;
|
|
106
|
+
}
|
|
107
|
+
if (stored.baseURL) {
|
|
108
|
+
result.baseURL = stored.baseURL;
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
/** Remove stored credentials for a provider (or all providers if omitted). */
|
|
113
|
+
export function clearProviderConfig(provider) {
|
|
114
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!provider) {
|
|
118
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const file = readCredentialsFile();
|
|
122
|
+
delete file[provider];
|
|
123
|
+
writeCredentialsFile(file);
|
|
124
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom provider - talks to any OpenAI chat-completions-compatible
|
|
3
|
+
* endpoint the user points it at: a self-hosted/local LLM server (LM
|
|
4
|
+
* Studio, vLLM, llama.cpp server, text-generation-webui, ...), or any
|
|
5
|
+
* other API that speaks the OpenAI wire format. Unlike the other
|
|
6
|
+
* OpenAI-compatible providers, there is no default base URL - the caller
|
|
7
|
+
* must supply one. Delegates the actual request/response handling to the
|
|
8
|
+
* shared OpenAI-compatible reviewer.
|
|
9
|
+
*/
|
|
10
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
11
|
+
export class CustomProvider {
|
|
12
|
+
name = "custom";
|
|
13
|
+
delegate;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
const baseURL = options.baseURL ?? process.env.CUSTOM_BASE_URL;
|
|
16
|
+
if (!baseURL) {
|
|
17
|
+
throw new Error('Missing endpoint URL for the custom provider. Set the CUSTOM_BASE_URL environment variable, or pass --base-url / { baseURL } (e.g. "http://localhost:1234/v1" for a local server, or the base URL of any OpenAI-compatible API).');
|
|
18
|
+
}
|
|
19
|
+
const model = options.model ?? process.env.CUSTOM_MODEL;
|
|
20
|
+
if (!model) {
|
|
21
|
+
throw new Error("Missing model for the custom provider. Set the CUSTOM_MODEL environment variable, or pass --model / { model }.");
|
|
22
|
+
}
|
|
23
|
+
// Many self-hosted/local servers don't require a real API key, but the
|
|
24
|
+
// OpenAI SDK still needs a non-empty string - fall back to a
|
|
25
|
+
// placeholder, same as the Ollama provider does.
|
|
26
|
+
const apiKey = options.apiKey ?? process.env.CUSTOM_API_KEY ?? "not-needed";
|
|
27
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
28
|
+
providerName: this.name,
|
|
29
|
+
apiKey,
|
|
30
|
+
model,
|
|
31
|
+
baseURL,
|
|
32
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async review(context) {
|
|
36
|
+
return this.delegate.review(context);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini provider - talks to Google's OpenAI-compatible endpoint for
|
|
3
|
+
* Gemini models at https://generativelanguage.googleapis.com/v1beta/openai/.
|
|
4
|
+
* Delegates the actual request/response handling to the shared
|
|
5
|
+
* OpenAI-compatible reviewer.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
|
|
9
|
+
export class GeminiProvider {
|
|
10
|
+
name = "gemini";
|
|
11
|
+
delegate;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("Missing Gemini API key. Set the GEMINI_API_KEY environment variable, or pass { apiKey } when constructing GeminiProvider.");
|
|
16
|
+
}
|
|
17
|
+
const model = options.model ?? process.env.GEMINI_MODEL;
|
|
18
|
+
if (!model) {
|
|
19
|
+
throw new Error('Missing Gemini model. Set the GEMINI_MODEL environment variable, or pass { model } when constructing GeminiProvider (e.g. "gemini-2.5-flash").');
|
|
20
|
+
}
|
|
21
|
+
const baseURL = options.baseURL ?? process.env.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL;
|
|
22
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
23
|
+
providerName: this.name,
|
|
24
|
+
apiKey,
|
|
25
|
+
model,
|
|
26
|
+
baseURL,
|
|
27
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async review(context) {
|
|
31
|
+
return this.delegate.review(context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./nvidiaProvider.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NVIDIA provider - talks to NVIDIA's OpenAI-chat-completions-compatible
|
|
3
|
+
* endpoint for NIM-hosted foundation models (e.g. Llama, Nemotron) at
|
|
4
|
+
* https://integrate.api.nvidia.com/v1. Delegates the actual request/response
|
|
5
|
+
* handling to the shared OpenAI-compatible reviewer.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export const DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
|
9
|
+
export class NvidiaProvider {
|
|
10
|
+
name = "nvidia";
|
|
11
|
+
delegate;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const apiKey = options.apiKey ?? process.env.NVIDIA_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("Missing NVIDIA API key. Set the NVIDIA_API_KEY environment variable, or pass { apiKey } when constructing NvidiaProvider.");
|
|
16
|
+
}
|
|
17
|
+
const model = options.model ?? process.env.NVIDIA_MODEL;
|
|
18
|
+
if (!model) {
|
|
19
|
+
throw new Error('Missing NVIDIA model. Set the NVIDIA_MODEL environment variable, or pass { model } when constructing NvidiaProvider (e.g. "meta/llama-3.1-70b-instruct" - see the model catalog at https://build.nvidia.com).');
|
|
20
|
+
}
|
|
21
|
+
const baseURL = options.baseURL ?? process.env.NVIDIA_BASE_URL ?? DEFAULT_NVIDIA_BASE_URL;
|
|
22
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
23
|
+
providerName: this.name,
|
|
24
|
+
apiKey,
|
|
25
|
+
model,
|
|
26
|
+
baseURL,
|
|
27
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async review(context) {
|
|
31
|
+
return this.delegate.review(context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama provider - talks to a local Ollama instance via its OpenAI
|
|
3
|
+
* chat-completions-compatible endpoint (http://localhost:11434/v1 by
|
|
4
|
+
* default). Delegates the actual request/response handling to
|
|
5
|
+
* createOpenAICompatibleReviewer.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
9
|
+
export class OllamaProvider {
|
|
10
|
+
name = "ollama";
|
|
11
|
+
delegate;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
// Ollama does not require a real API key for local use, but the OpenAI
|
|
14
|
+
// SDK requires some non-empty string - fall back to a placeholder.
|
|
15
|
+
const apiKey = options.apiKey ?? process.env.OLLAMA_API_KEY ?? "ollama";
|
|
16
|
+
const model = options.model ?? process.env.OLLAMA_MODEL;
|
|
17
|
+
if (!model) {
|
|
18
|
+
throw new Error('Missing Ollama model. Set the OLLAMA_MODEL environment variable, or pass { model } when ' +
|
|
19
|
+
'constructing OllamaProvider to the name of a model you have pulled locally (e.g. "ollama pull ' +
|
|
20
|
+
'llama3.1", then OLLAMA_MODEL=llama3.1). The model must support tool/function calling.');
|
|
21
|
+
}
|
|
22
|
+
const baseURL = options.baseURL ?? process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_BASE_URL;
|
|
23
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
24
|
+
providerName: this.name,
|
|
25
|
+
apiKey,
|
|
26
|
+
model,
|
|
27
|
+
baseURL,
|
|
28
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async review(context) {
|
|
32
|
+
return this.delegate.review(context);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI provider - implements ReviewProvider by delegating to the shared
|
|
3
|
+
* OpenAI-compatible reviewer (createOpenAICompatibleReviewer), which speaks
|
|
4
|
+
* the OpenAI chat-completions wire format and forces structured output via
|
|
5
|
+
* a "submit_review" tool call.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export class OpenAIProvider {
|
|
9
|
+
name = "openai";
|
|
10
|
+
delegate;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
13
|
+
if (!apiKey) {
|
|
14
|
+
throw new Error("Missing OpenAI API key. Set the OPENAI_API_KEY environment variable, or pass { apiKey } when constructing OpenAIProvider.");
|
|
15
|
+
}
|
|
16
|
+
const model = options.model ?? process.env.OPENAI_MODEL;
|
|
17
|
+
if (!model) {
|
|
18
|
+
throw new Error('Missing OpenAI model. Set the OPENAI_MODEL environment variable, or pass { model } when constructing OpenAIProvider (e.g. "gpt-4.1").');
|
|
19
|
+
}
|
|
20
|
+
const baseURL = options.baseURL ?? process.env.OPENAI_BASE_URL;
|
|
21
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
22
|
+
providerName: this.name,
|
|
23
|
+
apiKey,
|
|
24
|
+
model,
|
|
25
|
+
baseURL,
|
|
26
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async review(context) {
|
|
30
|
+
return this.delegate.review(context);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter provider - talks to OpenRouter's OpenAI-compatible endpoint at
|
|
3
|
+
* https://openrouter.ai/api/v1, which fronts many models (Claude, GPT,
|
|
4
|
+
* Gemini, Llama, and more) behind a single API key. Delegates the actual
|
|
5
|
+
* request/response handling to the shared OpenAI-compatible reviewer.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
9
|
+
export class OpenRouterProvider {
|
|
10
|
+
name = "openrouter";
|
|
11
|
+
delegate;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const apiKey = options.apiKey ?? process.env.OPENROUTER_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("Missing OpenRouter API key. Set the OPENROUTER_API_KEY environment variable, or pass { apiKey } when constructing OpenRouterProvider.");
|
|
16
|
+
}
|
|
17
|
+
const model = options.model ?? process.env.OPENROUTER_MODEL;
|
|
18
|
+
if (!model) {
|
|
19
|
+
throw new Error('Missing OpenRouter model. Set the OPENROUTER_MODEL environment variable, or pass { model } when constructing OpenRouterProvider (e.g. "anthropic/claude-sonnet-5" - see the model catalog at https://openrouter.ai/models).');
|
|
20
|
+
}
|
|
21
|
+
const baseURL = options.baseURL ?? process.env.OPENROUTER_BASE_URL ?? DEFAULT_OPENROUTER_BASE_URL;
|
|
22
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
23
|
+
providerName: this.name,
|
|
24
|
+
apiKey,
|
|
25
|
+
model,
|
|
26
|
+
baseURL,
|
|
27
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async review(context) {
|
|
31
|
+
return this.delegate.review(context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared base for any ReviewProvider that speaks the OpenAI chat-completions
|
|
3
|
+
* wire format - OpenAI itself, and OpenAI-compatible endpoints (NVIDIA NIM,
|
|
4
|
+
* Ollama's /v1 API). Each concrete provider (provider-openai, provider-nvidia,
|
|
5
|
+
* provider-ollama) just supplies its own api key / model / base URL defaults
|
|
6
|
+
* and delegates review() to the reviewer this factory returns.
|
|
7
|
+
*/
|
|
8
|
+
import OpenAI from "openai";
|
|
9
|
+
const SUBMIT_REVIEW_FUNCTION_NAME = "submit_review";
|
|
10
|
+
function buildSubmitReviewTool() {
|
|
11
|
+
return {
|
|
12
|
+
type: "function",
|
|
13
|
+
function: {
|
|
14
|
+
name: SUBMIT_REVIEW_FUNCTION_NAME,
|
|
15
|
+
description: "Submit the code review findings: an overall summary plus a list of issues discovered in the diff.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
summary: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "A concise, high-level summary of the overall review.",
|
|
22
|
+
},
|
|
23
|
+
issues: {
|
|
24
|
+
type: "array",
|
|
25
|
+
description: "The list of individual issues found while reviewing the diff.",
|
|
26
|
+
items: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
severity: {
|
|
30
|
+
type: "string",
|
|
31
|
+
enum: ["critical", "high", "medium", "low"],
|
|
32
|
+
description: "How severe this issue is.",
|
|
33
|
+
},
|
|
34
|
+
category: {
|
|
35
|
+
type: "string",
|
|
36
|
+
enum: ["security", "performance", "architecture", "style", "bug"],
|
|
37
|
+
description: "The category this issue falls under.",
|
|
38
|
+
},
|
|
39
|
+
title: { type: "string", description: "A short, human-readable title for the issue." },
|
|
40
|
+
description: { type: "string", description: "A detailed explanation of the issue." },
|
|
41
|
+
file: { type: "string", description: "The path of the file this issue was found in." },
|
|
42
|
+
line: { type: "number", description: "The line number in the file where this issue occurs." },
|
|
43
|
+
suggestion: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "A concrete suggestion for how to fix or address the issue.",
|
|
46
|
+
},
|
|
47
|
+
confidence: {
|
|
48
|
+
type: "number",
|
|
49
|
+
description: "A confidence score (0 to 1) that this issue is a real, valid finding.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: [
|
|
53
|
+
"severity",
|
|
54
|
+
"category",
|
|
55
|
+
"title",
|
|
56
|
+
"description",
|
|
57
|
+
"file",
|
|
58
|
+
"line",
|
|
59
|
+
"suggestion",
|
|
60
|
+
"confidence",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ["summary", "issues"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const submitReviewTool = buildSubmitReviewTool();
|
|
71
|
+
/**
|
|
72
|
+
* Builds a ReviewProvider that talks to any OpenAI chat-completions-compatible
|
|
73
|
+
* endpoint, forcing structured output via a named tool call (mirrors how
|
|
74
|
+
* ClaudeProvider forces its submit_review tool use).
|
|
75
|
+
*/
|
|
76
|
+
export function createOpenAICompatibleReviewer(options) {
|
|
77
|
+
const client = new OpenAI({ apiKey: options.apiKey, baseURL: options.baseURL });
|
|
78
|
+
return {
|
|
79
|
+
name: options.providerName,
|
|
80
|
+
async review(context) {
|
|
81
|
+
if (!context.prompt) {
|
|
82
|
+
throw new Error("ReviewContext.prompt is not set - run the Prompt Builder (buildPrompt) before calling provider.review().");
|
|
83
|
+
}
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await client.chat.completions.create({
|
|
88
|
+
model: options.model,
|
|
89
|
+
max_tokens: options.maxOutputTokens ?? 4096,
|
|
90
|
+
messages: [
|
|
91
|
+
{ role: "system", content: context.prompt.system },
|
|
92
|
+
{ role: "user", content: context.prompt.user },
|
|
93
|
+
],
|
|
94
|
+
tools: [submitReviewTool],
|
|
95
|
+
tool_choice: { type: "function", function: { name: SUBMIT_REVIEW_FUNCTION_NAME } },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new Error(`${options.providerName} API request failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
100
|
+
}
|
|
101
|
+
const toolCall = response.choices[0]?.message?.tool_calls?.find((call) => call.type === "function" && call.function.name === SUBMIT_REVIEW_FUNCTION_NAME);
|
|
102
|
+
if (!toolCall) {
|
|
103
|
+
throw new Error(`${options.providerName} did not return a submit_review tool call. Make sure the selected ` +
|
|
104
|
+
`model ("${options.model}") supports tool/function calling.`);
|
|
105
|
+
}
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(toolCall.function.arguments);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
throw new Error(`${options.providerName} returned malformed JSON for the submit_review tool call arguments.`, { cause: err });
|
|
112
|
+
}
|
|
113
|
+
const input = parsed;
|
|
114
|
+
return {
|
|
115
|
+
payload: {
|
|
116
|
+
summary: input.summary ?? "",
|
|
117
|
+
issues: input.issues ?? [],
|
|
118
|
+
},
|
|
119
|
+
provider: options.providerName,
|
|
120
|
+
model: options.model,
|
|
121
|
+
durationMs: Date.now() - start,
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "can-i-merge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "AI-powered Git Review Pipeline with Intelligent Context Building",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"license": "SEE LICENSE IN license",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@anthropic-ai/sdk": "^0.110.0",
|
|
35
|
-
"commander": "^15.0.0"
|
|
35
|
+
"commander": "^15.0.0",
|
|
36
|
+
"openai": "^6.45.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/node": "^26.1.0",
|