@balpal4495/quorum 3.3.2 → 3.3.3
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/bin/__tests__/chronicle.test.js +80 -0
- package/bin/shared/llm.js +88 -31
- package/package.json +3 -3
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import os from "os"
|
|
5
|
+
import { findChronicleDir } from "../shared/chronicle.js"
|
|
6
|
+
|
|
7
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
async function makeTmpDir() {
|
|
10
|
+
return fs.mkdtemp(path.join(os.tmpdir(), "quorum-test-"))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function rmrf(dir) {
|
|
14
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── findChronicleDir ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe("findChronicleDir", () => {
|
|
20
|
+
let tmpDir
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
tmpDir = await makeTmpDir()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await rmrf(tmpDir)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("returns the .chronicle path when found directly in startDir", async () => {
|
|
31
|
+
const chronicleDir = path.join(tmpDir, ".chronicle")
|
|
32
|
+
await fs.mkdir(chronicleDir)
|
|
33
|
+
|
|
34
|
+
const result = await findChronicleDir(tmpDir)
|
|
35
|
+
expect(result).toBe(chronicleDir)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("walks up the tree to find .chronicle in a parent directory", async () => {
|
|
39
|
+
const chronicleDir = path.join(tmpDir, ".chronicle")
|
|
40
|
+
await fs.mkdir(chronicleDir)
|
|
41
|
+
|
|
42
|
+
// Nested three levels deep — chronicle lives at root
|
|
43
|
+
const nested = path.join(tmpDir, "src", "components", "ui")
|
|
44
|
+
await fs.mkdir(nested, { recursive: true })
|
|
45
|
+
|
|
46
|
+
const result = await findChronicleDir(nested)
|
|
47
|
+
expect(result).toBe(chronicleDir)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("returns null when no .chronicle directory exists anywhere in the tree", async () => {
|
|
51
|
+
const nested = path.join(tmpDir, "src", "deep")
|
|
52
|
+
await fs.mkdir(nested, { recursive: true })
|
|
53
|
+
|
|
54
|
+
const result = await findChronicleDir(nested)
|
|
55
|
+
expect(result).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("ignores a .chronicle file (not a directory)", async () => {
|
|
59
|
+
// .chronicle exists as a file — must not be returned
|
|
60
|
+
await fs.writeFile(path.join(tmpDir, ".chronicle"), "not a dir")
|
|
61
|
+
|
|
62
|
+
const result = await findChronicleDir(tmpDir)
|
|
63
|
+
expect(result).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("finds the nearest .chronicle when multiple exist in ancestor chain", async () => {
|
|
67
|
+
// Parent has a .chronicle, nested subdir has its own .chronicle
|
|
68
|
+
const parentChronicle = path.join(tmpDir, ".chronicle")
|
|
69
|
+
await fs.mkdir(parentChronicle)
|
|
70
|
+
|
|
71
|
+
const subDir = path.join(tmpDir, "sub")
|
|
72
|
+
const subChronicle = path.join(subDir, ".chronicle")
|
|
73
|
+
await fs.mkdir(subDir)
|
|
74
|
+
await fs.mkdir(subChronicle)
|
|
75
|
+
|
|
76
|
+
// From inside subDir — should find the closer one
|
|
77
|
+
const result = await findChronicleDir(subDir)
|
|
78
|
+
expect(result).toBe(subChronicle)
|
|
79
|
+
})
|
|
80
|
+
})
|
package/bin/shared/llm.js
CHANGED
|
@@ -5,25 +5,52 @@ import path from "path"
|
|
|
5
5
|
const execAsync = promisify(exec)
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Auto-detect
|
|
8
|
+
* Auto-detect all available LLM providers from the environment and return a
|
|
9
|
+
* cascading provider that falls back automatically on quota / rate-limit errors.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
+
* Detection order (highest priority first):
|
|
11
12
|
* 1. ANTHROPIC_API_KEY → Anthropic Claude
|
|
12
13
|
* 2. OPENAI_API_KEY → OpenAI (or compatible via OPENAI_BASE_URL)
|
|
13
14
|
* 3. GEMINI_API_KEY → Google Gemini (API)
|
|
14
|
-
* 4. OPENAI_BASE_URL (no key) → OpenAI-compatible endpoint (Azure, Groq,
|
|
15
|
-
* 5. OLLAMA_HOST
|
|
16
|
-
* 6.
|
|
17
|
-
* 7. gemini CLI in PATH → Google Gemini (CLI subprocess)
|
|
15
|
+
* 4. OPENAI_BASE_URL (no key) → OpenAI-compatible endpoint (Azure, Groq, etc.)
|
|
16
|
+
* 5. OLLAMA_HOST / localhost:11434 → Ollama (probed in parallel with the above)
|
|
17
|
+
* 6. gemini CLI in PATH → Google Gemini (CLI subprocess)
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* All detected providers are tried in order. A 429 / quota / rate-limit error
|
|
20
|
+
* from one provider causes a silent fallback to the next rather than a hard
|
|
21
|
+
* failure. Only non-quota errors or exhausting all providers throws.
|
|
22
|
+
*
|
|
23
|
+
* Returns { llm, name } where name is the primary provider, or null if none found.
|
|
20
24
|
*/
|
|
21
25
|
export async function detectProvider() {
|
|
26
|
+
const candidates = await gatherCandidates()
|
|
27
|
+
if (candidates.length === 0) return null
|
|
28
|
+
if (candidates.length === 1) return candidates[0]
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
llm: createCascadingLLM(candidates),
|
|
32
|
+
name: candidates[0].name,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collect every available provider — all that can be detected run in parallel.
|
|
38
|
+
* Ollama is probed concurrently with API-key checks so it doesn't add latency.
|
|
39
|
+
*/
|
|
40
|
+
async function gatherCandidates() {
|
|
41
|
+
// Probe async sources concurrently
|
|
42
|
+
const [ollamaModel, geminiCLIAvail] = await Promise.all([
|
|
43
|
+
probeOllama(process.env.OLLAMA_HOST || "http://localhost:11434"),
|
|
44
|
+
probeGeminiCLI(),
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
const candidates = []
|
|
48
|
+
|
|
22
49
|
if (process.env.ANTHROPIC_API_KEY) {
|
|
23
|
-
|
|
50
|
+
candidates.push({
|
|
24
51
|
llm: createAnthropicProvider(process.env.ANTHROPIC_API_KEY),
|
|
25
52
|
name: "Anthropic",
|
|
26
|
-
}
|
|
53
|
+
})
|
|
27
54
|
}
|
|
28
55
|
|
|
29
56
|
if (process.env.OPENAI_API_KEY) {
|
|
@@ -31,45 +58,75 @@ export async function detectProvider() {
|
|
|
31
58
|
const name = base
|
|
32
59
|
? `OpenAI-compatible (${new URL(base).hostname})`
|
|
33
60
|
: "OpenAI"
|
|
34
|
-
|
|
61
|
+
candidates.push({
|
|
35
62
|
llm: createOpenAICompatProvider(process.env.OPENAI_API_KEY, base || "https://api.openai.com/v1"),
|
|
36
63
|
name,
|
|
37
|
-
}
|
|
64
|
+
})
|
|
65
|
+
} else if (process.env.OPENAI_BASE_URL) {
|
|
66
|
+
const base = process.env.OPENAI_BASE_URL.replace(/\/$/, "")
|
|
67
|
+
candidates.push({
|
|
68
|
+
llm: createOpenAICompatProvider("", base),
|
|
69
|
+
name: `OpenAI-compatible (${new URL(base).hostname})`,
|
|
70
|
+
})
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
if (process.env.GEMINI_API_KEY) {
|
|
41
|
-
|
|
74
|
+
candidates.push({
|
|
42
75
|
llm: createGeminiProvider(process.env.GEMINI_API_KEY),
|
|
43
76
|
name: "Gemini",
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (process.env.OPENAI_BASE_URL) {
|
|
48
|
-
const base = process.env.OPENAI_BASE_URL.replace(/\/$/, "")
|
|
49
|
-
return {
|
|
50
|
-
llm: createOpenAICompatProvider("", base),
|
|
51
|
-
name: `OpenAI-compatible (${new URL(base).hostname})`,
|
|
52
|
-
}
|
|
77
|
+
})
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
const ollamaHost = process.env.OLLAMA_HOST || "http://localhost:11434"
|
|
56
|
-
const ollamaModel = await probeOllama(ollamaHost)
|
|
57
80
|
if (ollamaModel) {
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
const host = process.env.OLLAMA_HOST || "http://localhost:11434"
|
|
82
|
+
candidates.push({
|
|
83
|
+
llm: createOpenAICompatProvider("", `${host}/v1`, ollamaModel),
|
|
60
84
|
name: `Ollama (${ollamaModel})`,
|
|
61
|
-
}
|
|
85
|
+
})
|
|
62
86
|
}
|
|
63
87
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return {
|
|
88
|
+
if (geminiCLIAvail) {
|
|
89
|
+
candidates.push({
|
|
67
90
|
llm: createGeminiCLIProvider(),
|
|
68
91
|
name: "Gemini CLI",
|
|
69
|
-
}
|
|
92
|
+
})
|
|
70
93
|
}
|
|
71
94
|
|
|
72
|
-
return
|
|
95
|
+
return candidates
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns true if the error looks like a quota / rate-limit response
|
|
100
|
+
* (HTTP 429, "quota exceeded", "rate limit", etc.).
|
|
101
|
+
*/
|
|
102
|
+
function isQuotaError(err) {
|
|
103
|
+
return /429|quota|rate.?limit/i.test(String(err?.message ?? ""))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wraps multiple provider functions into a single LLM function.
|
|
108
|
+
* On a quota error the next provider is tried automatically with a stderr notice.
|
|
109
|
+
* All other errors are re-thrown immediately from the failing provider.
|
|
110
|
+
*/
|
|
111
|
+
function createCascadingLLM(candidates) {
|
|
112
|
+
return async function llm(messages, model) {
|
|
113
|
+
let lastErr
|
|
114
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
115
|
+
try {
|
|
116
|
+
return await candidates[i].llm(messages, model)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
lastErr = err
|
|
119
|
+
if (isQuotaError(err) && i < candidates.length - 1) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
`\n ⚠ ${candidates[i].name} quota/rate-limit — falling back to ${candidates[i + 1].name}\n\n`,
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
throw err
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw lastErr
|
|
129
|
+
}
|
|
73
130
|
}
|
|
74
131
|
|
|
75
132
|
/** Convenience wrapper — returns the provider function or null. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@balpal4495/quorum",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "Git-backed memory and design review for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsc -p tsconfig.build.json",
|
|
46
|
-
"test": "vitest run modules/ evals/",
|
|
47
|
-
"test:watch": "vitest modules/",
|
|
46
|
+
"test": "vitest run modules/ evals/ bin/",
|
|
47
|
+
"test:watch": "vitest modules/ bin/",
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|