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 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", "claude")
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,4 @@
1
+ /**
2
+ * config package entry point. Re-exports the encrypted credential store.
3
+ */
4
+ export * from "./credentialStore.js";
@@ -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,6 @@
1
+ /**
2
+ * provider-custom package entry point. Re-exports the Custom provider.
3
+ * Does NOT register the provider into any registry - that wiring happens
4
+ * elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./customProvider.js";
@@ -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,6 @@
1
+ /**
2
+ * provider-gemini package entry point. Re-exports the Gemini provider.
3
+ * Does NOT register the provider into any registry - that wiring happens
4
+ * elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./geminiProvider.js";
@@ -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,6 @@
1
+ /**
2
+ * provider-ollama package entry point. Re-exports the Ollama provider.
3
+ * Does NOT register the provider into any registry - that wiring happens
4
+ * elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./ollamaProvider.js";
@@ -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,6 @@
1
+ /**
2
+ * provider-openai package entry point. Re-exports the OpenAI provider.
3
+ * Does NOT register the provider into any registry - that wiring happens
4
+ * elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./openAIProvider.js";
@@ -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,6 @@
1
+ /**
2
+ * provider-openrouter package entry point. Re-exports the OpenRouter
3
+ * provider. Does NOT register the provider into any registry - that wiring
4
+ * happens elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./openRouterProvider.js";
@@ -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.1.1",
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",