chorus-cli 0.5.1 → 0.5.4

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/index.js CHANGED
@@ -62,7 +62,7 @@ async function getMachineId() {
62
62
  }
63
63
 
64
64
  // Run coder.py with real-time stderr streaming so progress is visible
65
- function runCoder(prompt) {
65
+ function runCoder(prompt, { model } = {}) {
66
66
  return new Promise((resolve, reject) => {
67
67
  const env = { ...process.env };
68
68
  if (CONFIG.ai.chorusApiKey) {
@@ -72,7 +72,12 @@ function runCoder(prompt) {
72
72
  if (CONFIG.ai.machineId) {
73
73
  env.CHORUS_MACHINE_ID = CONFIG.ai.machineId;
74
74
  }
75
- const proc = spawn(CONFIG.ai.venvPython, [CONFIG.ai.coderPath, '--prompt', prompt], {
75
+ if (isFreeModel(model)) {
76
+ env.CHORUS_FREE = '1';
77
+ }
78
+ const coderArgs = [CONFIG.ai.coderPath, '--prompt', prompt];
79
+ if (model) { coderArgs.push('--model', model); }
80
+ const proc = spawn(CONFIG.ai.venvPython, coderArgs, {
76
81
  cwd: process.cwd(),
77
82
  env,
78
83
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -103,7 +108,7 @@ function runCoder(prompt) {
103
108
  }
104
109
 
105
110
  // Run qa.py with issue context on stdin, capture JSON from stdout
106
- function runQAChat(issue, enrichedDetails, qaName, useSuper = false) {
111
+ function runQAChat(issue, enrichedDetails, qaName, useSuper = false, { model } = {}) {
107
112
  return new Promise((resolve, reject) => {
108
113
  const input = JSON.stringify({
109
114
  issue_number: issue.number,
@@ -117,6 +122,7 @@ function runQAChat(issue, enrichedDetails, qaName, useSuper = false) {
117
122
  args.push('--auth', CONFIG.teams.authPath);
118
123
  }
119
124
  if (useSuper) {args.push('--super');}
125
+ if (model) {args.push('--model', model);}
120
126
 
121
127
  const env = { ...process.env };
122
128
  if (CONFIG.ai.chorusApiKey) {
@@ -126,6 +132,9 @@ function runQAChat(issue, enrichedDetails, qaName, useSuper = false) {
126
132
  if (CONFIG.ai.machineId) {
127
133
  env.CHORUS_MACHINE_ID = CONFIG.ai.machineId;
128
134
  }
135
+ if (isFreeModel(model)) {
136
+ env.CHORUS_FREE = '1';
137
+ }
129
138
  if (CONFIG.messenger === 'slack' && CONFIG.slack.botToken) {
130
139
  env.SLACK_BOT_TOKEN = CONFIG.slack.botToken;
131
140
  }
@@ -202,7 +211,7 @@ const CONFIG = {
202
211
  // Use createProvider(CONFIG, issueArg) to get the right provider.
203
212
 
204
213
  // ===== AI ENRICHMENT =====
205
- async function enrichWithAI(issue) {
214
+ async function enrichWithAI(issue, { model } = {}) {
206
215
  const prompt = `Analyze this GitHub issue and write questions for QA clarification.
207
216
 
208
217
  ISSUE DETAILS:
@@ -253,7 +262,7 @@ IMPORTANT: Output ONLY the message above. Do not include any preamble, thinking
253
262
  const openai = new OpenAI(openaiOpts);
254
263
 
255
264
  const response = await openai.chat.completions.create({
256
- model: 'chorus-default',
265
+ model: model || 'chorus-default',
257
266
  max_tokens: 2000,
258
267
  messages: [
259
268
  {
@@ -262,7 +271,10 @@ IMPORTANT: Output ONLY the message above. Do not include any preamble, thinking
262
271
  }
263
272
  ]
264
273
  }, {
265
- headers: { 'X-Chorus-Mode': 'enrich' },
274
+ headers: {
275
+ 'X-Chorus-Mode': 'enrich',
276
+ ...(isFreeModel(model) && { 'X-Chorus-Free': '1' }),
277
+ },
266
278
  });
267
279
 
268
280
  if (response.usage) {
@@ -279,7 +291,7 @@ IMPORTANT: Output ONLY the message above. Do not include any preamble, thinking
279
291
  }
280
292
 
281
293
  // ===== CODE GENERATION =====
282
- async function generateCode(issue, enrichedDetails, qaResponse) {
294
+ async function generateCode(issue, enrichedDetails, qaResponse, { model } = {}) {
283
295
  const tool = CONFIG.ai.codingTool;
284
296
 
285
297
  if (tool === 'coder') {
@@ -304,7 +316,7 @@ Instructions:
304
316
 
305
317
  console.log('🔨 Generating code with Coder agent...');
306
318
 
307
- return await runCoder(prompt);
319
+ return await runCoder(prompt, { model });
308
320
  }
309
321
 
310
322
  // Fallback: kimi
@@ -423,7 +435,7 @@ async function getCodeRabbitReview(solution, issue, provider) {
423
435
  }
424
436
  }
425
437
 
426
- async function refineCode(solution, review) {
438
+ async function refineCode(solution, review, { model } = {}) {
427
439
  const tool = CONFIG.ai.codingTool;
428
440
 
429
441
  if (tool === 'coder') {
@@ -448,7 +460,7 @@ Instructions:
448
460
 
449
461
  console.log('🔄 Refining code with Coder agent...');
450
462
 
451
- return await runCoder(prompt);
463
+ return await runCoder(prompt, { model });
452
464
  }
453
465
 
454
466
  // Fallback: kimi
@@ -535,6 +547,10 @@ ${lintOutput.slice(0, 5000)}`;
535
547
 
536
548
  // ===== TOKEN LIMIT =====
537
549
 
550
+ function isFreeModel(model) {
551
+ return model && FREE_MODELS.has(model);
552
+ }
553
+
538
554
  function isTokenLimitError(err) {
539
555
  const msg = typeof err === 'string' ? err : (err?.message || err?.error || '');
540
556
  return msg.includes('token limit exceeded') || msg.includes('rate_limit_error');
@@ -576,7 +592,7 @@ async function printTokenLimitMessage() {
576
592
  }
577
593
 
578
594
  // ===== MAIN WORKFLOW =====
579
- async function processTicket(issueArg, { useSuper = false, skipQA = false, qaName: qaNameOverride } = {}) {
595
+ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaName: qaNameOverride, model } = {}) {
580
596
  try {
581
597
  console.log('🚀 Starting ticket processing...\n');
582
598
 
@@ -600,7 +616,25 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
600
616
  efs(CONFIG.ai.venvPython, ['-m', 'pip', 'install', '-r', reqFile], { stdio: 'inherit' });
601
617
  }
602
618
 
603
- // 0a. Verify no modified tracked files (untracked files like .chorus/ are fine)
619
+ // 0a. Check token balance free models can proceed with zero balance
620
+ const usingFree = isFreeModel(model);
621
+ const email = await fetchAccountEmail();
622
+ const balance = await fetchCreditBalance(email);
623
+
624
+ if (balance !== null && balance <= 0 && !usingFree) {
625
+ await printTokenLimitMessage();
626
+ console.log(' Tip: Use --free to run with a free model\n');
627
+ process.exit(1);
628
+ }
629
+ if (usingFree) {
630
+ if (balance !== null && balance <= 0) {
631
+ console.log(`🆓 Using free model "${model}"\n`);
632
+ } else {
633
+ console.log(`🆓 Using free model "${model}"\n`);
634
+ }
635
+ }
636
+
637
+ // 0b. Verify no modified tracked files (untracked files like .chorus/ are fine)
604
638
  const { stdout: gitStatus } = await execPromise('git status --porcelain --untracked-files=no');
605
639
  if (gitStatus.trim()) {
606
640
  console.error('⚠️ Working directory has uncommitted changes. Commit or stash first:');
@@ -640,7 +674,7 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
640
674
  console.log(`Found issue #${issue.number}: ${issue.title}\n`);
641
675
 
642
676
  // 2. Enrich with AI
643
- const enrichedDetails = await enrichWithAI(issue);
677
+ const enrichedDetails = await enrichWithAI(issue, { model });
644
678
  console.log('Enrichment complete\n', enrichedDetails);
645
679
 
646
680
  // 3. Multi-turn QA conversation via qa.py
@@ -650,12 +684,13 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
650
684
  } else {
651
685
  const qaName = qaNameOverride || await provider.getUserDisplayName(issue.user.login);
652
686
  console.log(`💬 Starting QA conversation with ${qaName?.login}...`);
653
- const qaResult = await runQAChat(issue, enrichedDetails, qaName, useSuper);
687
+ const qaResult = await runQAChat(issue, enrichedDetails, qaName, useSuper, { model });
654
688
  qaResponse = qaResult.requirements;
655
689
 
656
690
  if (!qaResult.completed) {
657
- if (isTokenLimitError(qaResult.error)) {
691
+ if (isTokenLimitError(qaResult.error) && !usingFree) {
658
692
  await printTokenLimitMessage();
693
+ console.log(' Tip: Use --free to run with a free model\n');
659
694
  process.exit(1);
660
695
  }
661
696
  console.warn('⚠️ QA chat did not complete successfully:', qaResult.error || 'unknown');
@@ -678,7 +713,7 @@ async function processTicket(issueArg, { useSuper = false, skipQA = false, qaNam
678
713
 
679
714
  for (let attempt = 1; attempt <= maxCodeAttempts; attempt++) {
680
715
  if (attempt === 1) {
681
- solution = await generateCode(issue, enrichedDetails, qaResponse);
716
+ solution = await generateCode(issue, enrichedDetails, qaResponse, { model });
682
717
  } else {
683
718
  // Reprompt with explicit instruction that files must be written
684
719
  const retryPrompt = `You previously attempted to implement this issue but DID NOT write any files. Your task is NOT complete until you have actually created or modified files using write_file or edit_file.
@@ -699,13 +734,14 @@ ${qaResponse}
699
734
  CRITICAL: You MUST write code to actual files. Do not just describe changes — use write_file or edit_file to make them. If you are unsure where to make changes, explore the codebase first, then write the code.`;
700
735
 
701
736
  console.log(`🔁 Reprompting coder (attempt ${attempt}/${maxCodeAttempts})...`);
702
- solution = await runCoder(retryPrompt);
737
+ solution = await runCoder(retryPrompt, { model });
703
738
  }
704
739
 
705
740
  if (solution.completed === false) {
706
741
  const errs = solution.errors || [solution.summary || ''];
707
- if (errs.some(e => isTokenLimitError(e))) {
742
+ if (errs.some(e => isTokenLimitError(e)) && !usingFree) {
708
743
  printTokenLimitMessage();
744
+ console.log(' Tip: Use --free to run with a free model\n');
709
745
  process.exit(1);
710
746
  }
711
747
  console.error('❌ Code generation failed:', errs);
@@ -765,7 +801,7 @@ CRITICAL: You MUST write code to actual files. Do not just describe changes —
765
801
  while (review.needsChanges && iterations < maxIterations) {
766
802
  console.log(`Iteration ${iterations + 1}/${maxIterations}...`);
767
803
 
768
- const refined = await refineCode(solution, review);
804
+ const refined = await refineCode(solution, review, { model });
769
805
 
770
806
  if (refined.completed === false) {
771
807
  console.warn('⚠️ Refinement had errors:', refined.errors);
@@ -843,8 +879,9 @@ Instructions:
843
879
 
844
880
  if (revised.completed === false) {
845
881
  const errs = revised.errors || [revised.summary || ''];
846
- if (errs.some(e => isTokenLimitError(e))) {
882
+ if (errs.some(e => isTokenLimitError(e)) && !usingFree) {
847
883
  printTokenLimitMessage();
884
+ console.log(' Tip: Use --free to run with a free model\n');
848
885
  rl.close();
849
886
  process.exit(1);
850
887
  }
@@ -874,8 +911,9 @@ Instructions:
874
911
  console.log('\n✨ Ticket processing complete!');
875
912
 
876
913
  } catch (error) {
877
- if (isTokenLimitError(error)) {
914
+ if (isTokenLimitError(error) && !isFreeModel(model)) {
878
915
  await printTokenLimitMessage();
916
+ console.log(' Tip: Use --free to run with a free model\n');
879
917
  process.exit(1);
880
918
  }
881
919
  console.error('❌ Error processing ticket:', error);
@@ -1271,21 +1309,89 @@ function printZEP() {
1271
1309
  const command = process.argv[2];
1272
1310
  const _envExists = require('fs').existsSync(path.join(os.homedir(), '.config', 'chorus', '.env'));
1273
1311
 
1312
+ const FREE_MODELS = new Set([
1313
+ 'gpt-oss', 'gpt-oss-20b', 'qwen-coder', 'qwen', 'llama', 'hermes',
1314
+ 'nemotron', 'gemma', 'mistral', 'glm', 'step', 'solar'
1315
+ ]);
1316
+
1317
+ const PAID_MODELS = new Set([
1318
+ 'claude', 'chatgpt', 'deepseek', 'kimi', 'gemini', 'grok', 'command-r'
1319
+ ]);
1320
+
1321
+ const ALLOWED_MODELS = new Set([...FREE_MODELS, ...PAID_MODELS]);
1322
+
1323
+ const DEFAULT_FREE_MODEL = 'gpt-oss';
1324
+
1325
+ function printModelTable(out = console.error) {
1326
+ out('\n Free models:');
1327
+ out(' ┌─────────────────────┬──────────────────────────────────┐');
1328
+ out(' │ Flag value │ Model │');
1329
+ out(' ├─────────────────────┼──────────────────────────────────┤');
1330
+ out(' │ gpt-oss │ GPT-OSS 120B (default) │');
1331
+ out(' │ gpt-oss-20b │ GPT-OSS 20B │');
1332
+ out(' │ qwen-coder │ Qwen3 Coder 480B │');
1333
+ out(' │ qwen │ Qwen3 Next 80B │');
1334
+ out(' │ llama │ Llama 3.3 70B │');
1335
+ out(' │ hermes │ Hermes 3 405B │');
1336
+ out(' │ nemotron │ Nemotron 3 Nano 30B │');
1337
+ out(' │ gemma │ Gemma 3 27B │');
1338
+ out(' │ mistral │ Mistral Small 3.1 24B │');
1339
+ out(' │ glm │ GLM 4.5 Air │');
1340
+ out(' │ step │ Step 3.5 Flash │');
1341
+ out(' │ solar │ Solar Pro 3 │');
1342
+ out(' └─────────────────────┴──────────────────────────────────┘');
1343
+ out('\n Paid models:');
1344
+ out(' ┌─────────────────────┬──────────────────────────────────┐');
1345
+ out(' │ Flag value │ Model │');
1346
+ out(' ├─────────────────────┼──────────────────────────────────┤');
1347
+ out(' │ claude │ Claude │');
1348
+ out(' │ chatgpt │ ChatGPT │');
1349
+ out(' │ deepseek │ DeepSeek │');
1350
+ out(' │ kimi │ Kimi │');
1351
+ out(' │ gemini │ Gemini │');
1352
+ out(' │ grok │ Grok │');
1353
+ out(' │ command-r │ Command R │');
1354
+ out(' └─────────────────────┴──────────────────────────────────┘');
1355
+ }
1356
+
1274
1357
  function parseRunArgs() {
1275
1358
  const args = process.argv.slice(3);
1276
- const opts = { useSuper: false, skipQA: false, qaName: null, issueArg: null };
1359
+ const opts = { useSuper: false, skipQA: false, qaName: null, issueArg: null, model: null, free: false };
1277
1360
 
1278
1361
  for (let i = 0; i < args.length; i++) {
1279
1362
  if (args[i] === '--super') {
1280
1363
  opts.useSuper = true;
1281
1364
  } else if (args[i] === '--skip-qa') {
1282
1365
  opts.skipQA = true;
1366
+ } else if (args[i] === '--free') {
1367
+ opts.free = true;
1283
1368
  } else if (args[i] === '--qa' && i + 1 < args.length) {
1284
1369
  opts.qaName = args[++i];
1370
+ } else if (args[i] === '--model' && i + 1 < args.length) {
1371
+ const val = args[++i];
1372
+ if (!ALLOWED_MODELS.has(val)) {
1373
+ console.error(`Error: invalid model "${val}". Allowed models: ${[...ALLOWED_MODELS].join(', ')}`);
1374
+ printModelTable();
1375
+ process.exit(1);
1376
+ }
1377
+ opts.model = val;
1285
1378
  } else if (!args[i].startsWith('--')) {
1286
1379
  opts.issueArg = args[i];
1287
1380
  }
1288
1381
  }
1382
+
1383
+ // --free: default to best free model, or validate that --model is free
1384
+ if (opts.free) {
1385
+ if (opts.model && !FREE_MODELS.has(opts.model)) {
1386
+ console.error(`Error: "${opts.model}" is a paid model and cannot be used with --free.`);
1387
+ printModelTable();
1388
+ process.exit(1);
1389
+ }
1390
+ if (!opts.model) {
1391
+ opts.model = DEFAULT_FREE_MODEL;
1392
+ }
1393
+ }
1394
+
1289
1395
  return opts;
1290
1396
  }
1291
1397
 
@@ -1303,6 +1409,13 @@ if (command === 'setup') {
1303
1409
  const { issueArg, ...opts } = parseRunArgs();
1304
1410
  processTicket(issueArg, opts).catch(console.error);
1305
1411
  }
1412
+ } else if (command === 'models') {
1413
+ console.log('\nChorus — Available Models');
1414
+ printModelTable(console.log);
1415
+ console.log('\nUsage:');
1416
+ console.log(' chorus run 4464 --model deepseek Use a specific model');
1417
+ console.log(' chorus run 4464 --free Use the best free model (GPT-OSS 120B)');
1418
+ console.log(' chorus run 4464 --free --model qwen-coder Use a specific free model\n');
1306
1419
  } else if (command === 'zep') {
1307
1420
  printZEP()
1308
1421
  } else {
@@ -1321,10 +1434,14 @@ Chorus — AI-powered ticket automation (GitHub & Azure DevOps)
1321
1434
 
1322
1435
  Usage:
1323
1436
  chorus setup - Set up provider, Chorus auth + messenger
1437
+ chorus models - List all available models (free & paid)
1324
1438
  chorus run - Process latest assigned issue
1325
1439
  chorus run 4464 - Process specific issue by number
1326
1440
  chorus run <url> - Process issue from full URL (auto-detects provider)
1327
1441
  chorus run 4464 --super - Use Opus 4.6 for QA evaluation
1442
+ chorus run 4464 --model deepseek - Use a specific model (see --free for free options)
1443
+ chorus run 4464 --free - Use the best free model (GPT-OSS 120B)
1444
+ chorus run 4464 --free --model qwen-coder - Use a specific free model
1328
1445
  chorus run 4464 --qa 'John Doe' - Specify QA contact name for chat
1329
1446
  chorus run 4464 --skip-qa - Skip QA conversation, go straight to coding
1330
1447
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chorus-cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.4",
4
4
  "description": "Automated ticket resolution with AI, Teams, and Slack integration",
5
5
  "main": "index.js",
6
6
  "bin": {
package/tools/coder.py CHANGED
@@ -878,8 +878,13 @@ def run_prompt(client, prompt, chorus_context):
878
878
  def main():
879
879
  parser = argparse.ArgumentParser(description="Coder — AI coding agent powered by Claude via Chorus")
880
880
  parser.add_argument("-p", "--prompt", help="Run a single prompt headlessly and output JSON")
881
+ parser.add_argument("--model", help="Override the model sent to the proxy")
881
882
  args = parser.parse_args()
882
883
 
884
+ global MODEL
885
+ if args.model:
886
+ MODEL = args.model
887
+
883
888
  api_key = os.environ.get("CHORUS_API_KEY")
884
889
  if not api_key:
885
890
  print(f"{C.RED}Error: CHORUS_API_KEY not set. Run 'chorus setup' to configure.{C.RESET}", file=sys.stderr)
@@ -887,9 +892,15 @@ def main():
887
892
 
888
893
  base_url = os.environ.get("CHORUS_API_URL", "https://chorus-bad0f.web.app/v1")
889
894
  machine_id = os.environ.get("CHORUS_MACHINE_ID")
890
- client_kwargs = {"api_key": api_key, "base_url": base_url}
895
+ chorus_free = os.environ.get("CHORUS_FREE", "")
896
+ default_headers = {}
891
897
  if machine_id:
892
- client_kwargs["default_headers"] = {"X-Machine-Id": machine_id}
898
+ default_headers["X-Machine-Id"] = machine_id
899
+ if chorus_free:
900
+ default_headers["X-Chorus-Free"] = "1"
901
+ client_kwargs = {"api_key": api_key, "base_url": base_url}
902
+ if default_headers:
903
+ client_kwargs["default_headers"] = default_headers
893
904
  client = OpenAI(**client_kwargs)
894
905
  cwd = os.getcwd()
895
906
 
package/tools/qa.py CHANGED
@@ -408,9 +408,15 @@ def run_qa_chat(issue_context, messenger, qa_name):
408
408
  api_key = os.environ.get("CHORUS_API_KEY")
409
409
  base_url = os.environ.get("CHORUS_API_URL", "https://chorus-bad0f.web.app/v1")
410
410
  machine_id = os.environ.get("CHORUS_MACHINE_ID")
411
- client_kwargs = {"api_key": api_key, "base_url": base_url}
411
+ chorus_free = os.environ.get("CHORUS_FREE", "")
412
+ default_headers = {}
412
413
  if machine_id:
413
- client_kwargs["default_headers"] = {"X-Machine-Id": machine_id}
414
+ default_headers["X-Machine-Id"] = machine_id
415
+ if chorus_free:
416
+ default_headers["X-Chorus-Free"] = "1"
417
+ client_kwargs = {"api_key": api_key, "base_url": base_url}
418
+ if default_headers:
419
+ client_kwargs["default_headers"] = default_headers
414
420
  client = OpenAI(**client_kwargs)
415
421
  conversation = []
416
422
  raw_responses = []
@@ -483,8 +489,14 @@ def main():
483
489
  parser.add_argument("--auth", help="Path to Teams auth state JSON (required for --messenger teams)")
484
490
  parser.add_argument("--qa", required=True, help="QA person's name")
485
491
  parser.add_argument("--super", action="store_true", help="Use Opus 4.6 instead of Sonnet")
492
+ parser.add_argument("--model", help="Override the model sent to the proxy")
486
493
  args = parser.parse_args()
487
494
 
495
+ # Override model if specified
496
+ global MODEL
497
+ if args.model:
498
+ MODEL = args.model
499
+
488
500
  # chorus_mode tells the proxy which model to use
489
501
  global QA_CHORUS_MODE
490
502
  if args.super: