git-copilot-commit 0.6.1__py3-none-any.whl → 0.7.0__py3-none-any.whl

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.
git_copilot_commit/cli.py CHANGED
@@ -81,8 +81,8 @@ BaseUrlOption = Annotated[
81
81
  cyclopts.Parameter(
82
82
  name="--base-url",
83
83
  help=(
84
- "Base URL for an OpenAI-compatible provider, for example "
85
- "http://127.0.0.1:11434/v1."
84
+ "Endpoint URL for an OpenAI-compatible provider, for example "
85
+ "http://127.0.0.1:11434/v1/chat/completions."
86
86
  ),
87
87
  ),
88
88
  ]
@@ -371,6 +371,8 @@ def ask_llm_with_system_prompt(
371
371
  model: str | None = None,
372
372
  provider_config: providers.ProviderConfig | None = None,
373
373
  http_client_config: llm.HttpClientConfig | None = None,
374
+ disable_thinking: bool = True,
375
+ max_tokens: int = 1024,
374
376
  ) -> str:
375
377
  """Send a prepared prompt to the selected LLM provider."""
376
378
  return providers.ask(
@@ -386,6 +388,8 @@ def ask_llm_with_system_prompt(
386
388
  provider_config=provider_config,
387
389
  model=normalize_model_name(model),
388
390
  http_client_config=http_client_config,
391
+ disable_thinking=disable_thinking,
392
+ max_tokens=max_tokens,
389
393
  )
390
394
 
391
395
 
@@ -394,6 +398,8 @@ def generate_commit_message_for_prompt(
394
398
  model: str | None = None,
395
399
  provider_config: providers.ProviderConfig | None = None,
396
400
  http_client_config: llm.HttpClientConfig | None = None,
401
+ disable_thinking: bool = True,
402
+ max_tokens: int = 1024,
397
403
  ) -> str:
398
404
  """Generate a conventional commit message from a prepared prompt."""
399
405
  return ask_llm_with_system_prompt(
@@ -402,6 +408,8 @@ def generate_commit_message_for_prompt(
402
408
  model=model,
403
409
  provider_config=provider_config,
404
410
  http_client_config=http_client_config,
411
+ disable_thinking=disable_thinking,
412
+ max_tokens=max_tokens,
405
413
  )
406
414
 
407
415
 
@@ -435,6 +443,8 @@ def generate_commit_message_for_status(
435
443
  context: str = "",
436
444
  provider_config: providers.ProviderConfig | None = None,
437
445
  http_client_config: llm.HttpClientConfig | None = None,
446
+ disable_thinking: bool = True,
447
+ max_tokens: int = 1024,
438
448
  ) -> str:
439
449
  """Generate a commit message for a staged status snapshot."""
440
450
  full_prompt = build_commit_message_prompt(status, context=context)
@@ -444,6 +454,8 @@ def generate_commit_message_for_status(
444
454
  model=model,
445
455
  provider_config=provider_config,
446
456
  http_client_config=http_client_config,
457
+ disable_thinking=disable_thinking,
458
+ max_tokens=max_tokens,
447
459
  )
448
460
  except llm.LLMError as exc:
449
461
  if not should_retry_with_compact_prompt(exc):
@@ -462,6 +474,8 @@ def generate_commit_message_for_status(
462
474
  model=model,
463
475
  provider_config=provider_config,
464
476
  http_client_config=http_client_config,
477
+ disable_thinking=disable_thinking,
478
+ max_tokens=max_tokens,
465
479
  )
466
480
 
467
481
 
@@ -549,6 +563,8 @@ def request_commit_message(
549
563
  context: str = "",
550
564
  provider_config: providers.ProviderConfig | None = None,
551
565
  http_client_config: llm.HttpClientConfig | None = None,
566
+ disable_thinking: bool = True,
567
+ max_tokens: int = 1024,
552
568
  ) -> str:
553
569
  """Request a commit message for the provided staged state."""
554
570
  try:
@@ -561,6 +577,8 @@ def request_commit_message(
561
577
  context=context,
562
578
  provider_config=provider_config,
563
579
  http_client_config=http_client_config,
580
+ disable_thinking=disable_thinking,
581
+ max_tokens=max_tokens,
564
582
  )
565
583
  except llm.LLMError as exc:
566
584
  print_llm_error("Could not generate a commit message", exc)
@@ -576,6 +594,8 @@ def request_split_commit_plan(
576
594
  context: str = "",
577
595
  provider_config: providers.ProviderConfig | None = None,
578
596
  http_client_config: llm.HttpClientConfig | None = None,
597
+ disable_thinking: bool = True,
598
+ max_tokens: int = 1024,
579
599
  ) -> SplitCommitPlan:
580
600
  """Request and validate a split-commit plan for the staged patch units."""
581
601
  planner_system_prompt = load_named_prompt(SPLIT_COMMIT_PLANNER_PROMPT_FILENAME)
@@ -596,6 +616,8 @@ def request_split_commit_plan(
596
616
  model=model,
597
617
  provider_config=provider_config,
598
618
  http_client_config=http_client_config,
619
+ disable_thinking=disable_thinking,
620
+ max_tokens=max_tokens,
599
621
  )
600
622
  except llm.LLMError as exc:
601
623
  if not should_retry_with_compact_prompt(exc):
@@ -629,6 +651,8 @@ def request_split_commit_plan(
629
651
  model=model,
630
652
  provider_config=provider_config,
631
653
  http_client_config=http_client_config,
654
+ disable_thinking=disable_thinking,
655
+ max_tokens=max_tokens,
632
656
  )
633
657
  except llm.LLMError as exc:
634
658
  print_llm_error("Could not generate a split commit plan", exc)
@@ -648,6 +672,8 @@ def request_split_commit_messages(
648
672
  context: str = "",
649
673
  provider_config: providers.ProviderConfig | None = None,
650
674
  http_client_config: llm.HttpClientConfig | None = None,
675
+ disable_thinking: bool = True,
676
+ max_tokens: int = 1024,
651
677
  ) -> list[PreparedSplitCommit]:
652
678
  """Generate commit messages for each planned split-commit group."""
653
679
  try:
@@ -665,6 +691,8 @@ def request_split_commit_messages(
665
691
  context=context,
666
692
  provider_config=provider_config,
667
693
  http_client_config=http_client_config,
694
+ disable_thinking=disable_thinking,
695
+ max_tokens=max_tokens,
668
696
  )
669
697
 
670
698
  prepared_commits.append(
@@ -858,6 +886,8 @@ def handle_single_commit_flow(
858
886
  context: str = "",
859
887
  provider_config: providers.ProviderConfig | None = None,
860
888
  http_client_config: llm.HttpClientConfig | None = None,
889
+ disable_thinking: bool = True,
890
+ max_tokens: int = 1024,
861
891
  ) -> None:
862
892
  """Generate, display, and execute the single-commit flow."""
863
893
  commit_message = request_commit_message(
@@ -866,6 +896,8 @@ def handle_single_commit_flow(
866
896
  context=context,
867
897
  provider_config=provider_config,
868
898
  http_client_config=http_client_config,
899
+ disable_thinking=disable_thinking,
900
+ max_tokens=max_tokens,
869
901
  )
870
902
  display_commit_message(commit_message)
871
903
 
@@ -883,6 +915,8 @@ def handle_split_commit_flow(
883
915
  context: str = "",
884
916
  provider_config: providers.ProviderConfig | None = None,
885
917
  http_client_config: llm.HttpClientConfig | None = None,
918
+ disable_thinking: bool = True,
919
+ max_tokens: int = 1024,
886
920
  ) -> None:
887
921
  """Generate, display, and execute the split-commit flow."""
888
922
  patch_units = tuple(
@@ -901,6 +935,8 @@ def handle_split_commit_flow(
901
935
  context=context,
902
936
  provider_config=provider_config,
903
937
  http_client_config=http_client_config,
938
+ disable_thinking=disable_thinking,
939
+ max_tokens=max_tokens,
904
940
  )
905
941
  return
906
942
 
@@ -916,6 +952,8 @@ def handle_split_commit_flow(
916
952
  context=context,
917
953
  provider_config=provider_config,
918
954
  http_client_config=http_client_config,
955
+ disable_thinking=disable_thinking,
956
+ max_tokens=max_tokens,
919
957
  )
920
958
  return
921
959
 
@@ -938,6 +976,8 @@ def handle_split_commit_flow(
938
976
  context=context,
939
977
  provider_config=provider_config,
940
978
  http_client_config=http_client_config,
979
+ disable_thinking=disable_thinking,
980
+ max_tokens=max_tokens,
941
981
  )
942
982
  except SplitPlanningError as exc:
943
983
  console.print(
@@ -952,6 +992,8 @@ def handle_split_commit_flow(
952
992
  context=context,
953
993
  provider_config=provider_config,
954
994
  http_client_config=http_client_config,
995
+ disable_thinking=disable_thinking,
996
+ max_tokens=max_tokens,
955
997
  )
956
998
  return
957
999
 
@@ -969,6 +1011,8 @@ def handle_split_commit_flow(
969
1011
  context=context,
970
1012
  provider_config=provider_config,
971
1013
  http_client_config=http_client_config,
1014
+ disable_thinking=disable_thinking,
1015
+ max_tokens=max_tokens,
972
1016
  )
973
1017
  prepared_commits = order_prepared_split_commits(prepared_commits)
974
1018
 
@@ -1008,7 +1052,7 @@ def authenticate(
1008
1052
  ] = False,
1009
1053
  ca_bundle: CaBundleOption = None,
1010
1054
  insecure: InsecureOption = False,
1011
- native_tls: NativeTlsOption = False,
1055
+ native_tls: NativeTlsOption = True,
1012
1056
  ):
1013
1057
  """Authenticate with GitHub Copilot and cache credentials locally."""
1014
1058
  print_cli_banner()
@@ -1035,7 +1079,7 @@ def summary(
1035
1079
  api_key: ApiKeyOption = None,
1036
1080
  ca_bundle: CaBundleOption = None,
1037
1081
  insecure: InsecureOption = False,
1038
- native_tls: NativeTlsOption = False,
1082
+ native_tls: NativeTlsOption = True,
1039
1083
  ):
1040
1084
  """Show the configured LLM provider summary."""
1041
1085
  print_cli_banner()
@@ -1073,7 +1117,7 @@ def models_command(
1073
1117
  ] = None,
1074
1118
  ca_bundle: CaBundleOption = None,
1075
1119
  insecure: InsecureOption = False,
1076
- native_tls: NativeTlsOption = False,
1120
+ native_tls: NativeTlsOption = True,
1077
1121
  ):
1078
1122
  """List available models for the configured LLM provider."""
1079
1123
  print_cli_banner()
@@ -1139,12 +1183,30 @@ def commit(
1139
1183
  help="Optional user-provided context to guide commit message",
1140
1184
  ),
1141
1185
  ] = "",
1186
+ disable_thinking: Annotated[
1187
+ bool,
1188
+ cyclopts.Parameter(
1189
+ name="--disable-thinking",
1190
+ negative="--enable-thinking",
1191
+ help=(
1192
+ "Disable or minimize reasoning/thinking tokens for commit-message requests."
1193
+ ),
1194
+ ),
1195
+ ] = True,
1196
+ max_tokens: Annotated[
1197
+ int,
1198
+ cyclopts.Parameter(
1199
+ name="--max-tokens",
1200
+ help=("Maximum output tokens for LLM generation."),
1201
+ validator=cyclopts.validators.Number(gte=1),
1202
+ ),
1203
+ ] = 1024,
1142
1204
  provider: ProviderOption = None,
1143
1205
  base_url: BaseUrlOption = None,
1144
1206
  api_key: ApiKeyOption = None,
1145
1207
  ca_bundle: CaBundleOption = None,
1146
1208
  insecure: InsecureOption = False,
1147
- native_tls: NativeTlsOption = False,
1209
+ native_tls: NativeTlsOption = True,
1148
1210
  ):
1149
1211
  """
1150
1212
  Generate commit message based on changes in the current git repository and commit them.
@@ -1212,6 +1274,8 @@ def commit(
1212
1274
  context=context,
1213
1275
  provider_config=provider_config,
1214
1276
  http_client_config=http_client_config,
1277
+ disable_thinking=disable_thinking,
1278
+ max_tokens=max_tokens,
1215
1279
  )
1216
1280
  return
1217
1281
 
@@ -1223,6 +1287,8 @@ def commit(
1223
1287
  context=context,
1224
1288
  provider_config=provider_config,
1225
1289
  http_client_config=http_client_config,
1290
+ disable_thinking=disable_thinking,
1291
+ max_tokens=max_tokens,
1226
1292
  )
1227
1293
 
1228
1294
 
@@ -500,7 +500,13 @@ def list_models(client, credentials: CopilotCredentials) -> list[Model]:
500
500
 
501
501
 
502
502
  def complete_text_prompt(
503
- client, credentials: CopilotCredentials, *, model: Model, prompt: str
503
+ client,
504
+ credentials: CopilotCredentials,
505
+ *,
506
+ model: Model,
507
+ prompt: str,
508
+ disable_thinking: bool = False,
509
+ max_tokens: int | None = None,
504
510
  ) -> str:
505
511
  api_surface = llm.infer_api_surface(model)
506
512
  if api_surface == "chat_completions":
@@ -512,6 +518,8 @@ def complete_text_prompt(
512
518
  ),
513
519
  model_id=model.id,
514
520
  prompt=prompt,
521
+ disable_thinking=disable_thinking,
522
+ max_tokens=max_tokens,
515
523
  )
516
524
  if api_surface == "responses":
517
525
  return llm.responses_completion_request(
@@ -524,6 +532,8 @@ def complete_text_prompt(
524
532
  ),
525
533
  model_id=model.id,
526
534
  prompt=prompt,
535
+ disable_thinking=disable_thinking,
536
+ max_tokens=max_tokens,
527
537
  )
528
538
 
529
539
  raise LLMError(
@@ -778,6 +788,8 @@ def ask(
778
788
  default_model: str | None = None,
779
789
  configured_default_model_path: Path | None = None,
780
790
  http_client_config: HttpClientConfig | None = None,
791
+ disable_thinking: bool = False,
792
+ max_tokens: int | None = None,
781
793
  ) -> str:
782
794
  def run(client) -> str:
783
795
  credentials = ensure_fresh_credentials(client)
@@ -794,6 +806,8 @@ def ask(
794
806
  credentials,
795
807
  model=selected_model,
796
808
  prompt=prompt,
809
+ disable_thinking=disable_thinking,
810
+ max_tokens=max_tokens,
797
811
  )
798
812
 
799
813
  return _with_reauthentication(run, http_client_config=http_client_config)
@@ -142,7 +142,7 @@ class Model:
142
142
 
143
143
  @dataclass(frozen=True, slots=True)
144
144
  class HttpClientConfig:
145
- native_tls: bool = False
145
+ native_tls: bool = True
146
146
  insecure: bool = False
147
147
  ca_bundle: str | None = None
148
148
 
@@ -441,6 +441,80 @@ def filter_models_by_vendor(
441
441
  return filtered
442
442
 
443
443
 
444
+ def _model_id_matches(model_id: str, keywords: tuple[str, ...]) -> bool:
445
+ normalized = model_id.lower()
446
+ return any(keyword in normalized for keyword in keywords)
447
+
448
+
449
+ def _is_openai_reasoning_model(model_id: str) -> bool:
450
+ normalized = model_id.lower()
451
+ return normalized.startswith(("o1", "o3", "o4")) or "/o" in normalized
452
+
453
+
454
+ def _uses_chat_template_thinking_controls(model_id: str) -> bool:
455
+ return _model_id_matches(
456
+ model_id,
457
+ (
458
+ "qwen",
459
+ "deepseek",
460
+ "granite",
461
+ "glm",
462
+ "hunyuan",
463
+ "magistral",
464
+ "mistral",
465
+ "nemotron",
466
+ "seed",
467
+ "step",
468
+ ),
469
+ )
470
+
471
+
472
+ def disable_thinking_options(
473
+ *,
474
+ model_id: str,
475
+ api_surface: str,
476
+ ) -> dict[str, Any]:
477
+ normalized = model_id.lower()
478
+
479
+ if api_surface == "responses":
480
+ if "codex" in normalized:
481
+ return {"reasoning": {"effort": "none"}}
482
+ if "gpt-5" in normalized:
483
+ return {"reasoning": {"effort": "minimal"}}
484
+ if "gpt-oss" in normalized or _is_openai_reasoning_model(model_id):
485
+ return {"reasoning": {"effort": "low"}}
486
+ if _uses_chat_template_thinking_controls(model_id):
487
+ return {
488
+ "reasoning_effort": "none",
489
+ "chat_template_kwargs": {
490
+ "enable_thinking": False,
491
+ "thinking": False,
492
+ },
493
+ }
494
+ return {}
495
+
496
+ if "gemini" in normalized:
497
+ return {"reasoning_effort": "none"}
498
+ if "codex" in normalized:
499
+ return {"reasoning_effort": "none"}
500
+ if "gpt-5" in normalized:
501
+ return {"reasoning_effort": "minimal"}
502
+ if "gpt-oss" in normalized or _is_openai_reasoning_model(model_id):
503
+ return {"reasoning_effort": "low"}
504
+ if "claude" in normalized or "anthropic" in normalized:
505
+ return {"thinking": {"type": "disabled"}}
506
+ if _uses_chat_template_thinking_controls(model_id):
507
+ return {
508
+ "reasoning_effort": "none",
509
+ "chat_template_kwargs": {
510
+ "enable_thinking": False,
511
+ "thinking": False,
512
+ },
513
+ }
514
+
515
+ return {}
516
+
517
+
444
518
  def extract_completion_text(payload: Any) -> str:
445
519
  if not isinstance(payload, dict):
446
520
  raise LLMError("Chat completion returned an invalid payload.")
@@ -459,7 +533,9 @@ def extract_completion_text(payload: Any) -> str:
459
533
 
460
534
  content = message.get("content")
461
535
  if isinstance(content, str):
462
- return content.strip()
536
+ stripped = content.strip()
537
+ if stripped:
538
+ return stripped
463
539
 
464
540
  if isinstance(content, list):
465
541
  text_parts: list[str] = []
@@ -477,6 +553,41 @@ def extract_completion_text(payload: Any) -> str:
477
553
  if joined:
478
554
  return joined
479
555
 
556
+ finish_reason = choice.get("finish_reason")
557
+ reasoning = message.get("reasoning")
558
+ has_reasoning = isinstance(reasoning, str) and reasoning.strip()
559
+ finish_reason_detail: str | None = None
560
+ if finish_reason is not None:
561
+ try:
562
+ finish_reason_detail = json.dumps(finish_reason)
563
+ except TypeError:
564
+ finish_reason_detail = repr(finish_reason)
565
+ finish_reason_detail = truncate_response_detail(finish_reason_detail)
566
+
567
+ if finish_reason == "length":
568
+ detail = (
569
+ " The response contained reasoning text but no final assistant content."
570
+ if has_reasoning
571
+ else ""
572
+ )
573
+ raise LLMError(
574
+ "Chat completion reached the completion token limit before returning "
575
+ 'message content (finish_reason="length").'
576
+ f"{detail} Increase `max_tokens` or reduce the prompt."
577
+ )
578
+
579
+ if finish_reason_detail is not None:
580
+ raise LLMError(
581
+ "Chat completion message content was empty "
582
+ f"(finish_reason={finish_reason_detail})."
583
+ )
584
+
585
+ if has_reasoning:
586
+ raise LLMError(
587
+ "Chat completion message content was empty. The response contained "
588
+ "reasoning text but no final assistant content."
589
+ )
590
+
480
591
  raise LLMError("Chat completion message content was empty.")
481
592
 
482
593
 
@@ -487,24 +598,35 @@ def chat_completion_request(
487
598
  *,
488
599
  model_id: str,
489
600
  prompt: str,
601
+ disable_thinking: bool = False,
602
+ max_tokens: int | None = None,
490
603
  ) -> str:
604
+ request_body: dict[str, Any] = {
605
+ "model": model_id,
606
+ "messages": [
607
+ {
608
+ "role": "user",
609
+ "content": prompt,
610
+ }
611
+ ],
612
+ "temperature": 0,
613
+ "max_tokens": max_tokens if max_tokens is not None else 1024,
614
+ "stream": False,
615
+ }
616
+ if disable_thinking:
617
+ request_body.update(
618
+ disable_thinking_options(
619
+ model_id=model_id,
620
+ api_surface="chat_completions",
621
+ )
622
+ )
623
+
491
624
  payload = request_json(
492
625
  client,
493
626
  "POST",
494
627
  url,
495
628
  headers=headers,
496
- json_body={
497
- "model": model_id,
498
- "messages": [
499
- {
500
- "role": "user",
501
- "content": prompt,
502
- }
503
- ],
504
- "temperature": 0,
505
- "max_tokens": 1024,
506
- "stream": False,
507
- },
629
+ json_body=request_body,
508
630
  )
509
631
  return extract_completion_text(payload)
510
632
 
@@ -551,6 +673,46 @@ def extract_response_text(payload: Any) -> str:
551
673
  raise LLMError("Responses API output did not contain text.")
552
674
 
553
675
 
676
+ def response_output_contains_reasoning(payload: dict[str, Any]) -> bool:
677
+ output = payload.get("output")
678
+ if not isinstance(output, list):
679
+ return False
680
+
681
+ for item in output:
682
+ if not isinstance(item, dict):
683
+ continue
684
+ if item.get("type") == "reasoning":
685
+ return True
686
+ content = item.get("content")
687
+ if not isinstance(content, list):
688
+ continue
689
+ for block in content:
690
+ if isinstance(block, dict) and block.get("type") in {
691
+ "reasoning_text",
692
+ "reasoning_summary",
693
+ }:
694
+ return True
695
+
696
+ return False
697
+
698
+
699
+ def format_incomplete_response_error(
700
+ *,
701
+ reason: str,
702
+ final_response: dict[str, Any],
703
+ ) -> str:
704
+ message = f"Responses API response was incomplete: {reason}."
705
+ if reason == "max_output_tokens":
706
+ message += " Increase `--max-tokens` or reduce the prompt."
707
+ if response_output_contains_reasoning(final_response):
708
+ message += (
709
+ " The response contained reasoning output before final text; if this "
710
+ "provider cannot disable reasoning on `/responses`, use its "
711
+ "`/chat/completions` endpoint instead."
712
+ )
713
+ return message
714
+
715
+
554
716
  def responses_completion_request(
555
717
  client: httpx.Client,
556
718
  url: str,
@@ -558,8 +720,10 @@ def responses_completion_request(
558
720
  *,
559
721
  model_id: str,
560
722
  prompt: str,
723
+ disable_thinking: bool = False,
724
+ max_tokens: int | None = None,
561
725
  ) -> str:
562
- request_body = {
726
+ request_body: dict[str, Any] = {
563
727
  "model": model_id,
564
728
  "input": [
565
729
  {
@@ -575,6 +739,15 @@ def responses_completion_request(
575
739
  "stream": True,
576
740
  "store": False,
577
741
  }
742
+ if max_tokens is not None:
743
+ request_body["max_output_tokens"] = max_tokens
744
+ if disable_thinking:
745
+ request_body.update(
746
+ disable_thinking_options(
747
+ model_id=model_id,
748
+ api_surface="responses",
749
+ )
750
+ )
578
751
 
579
752
  for attempt in range(HTTP_RETRY_ATTEMPTS):
580
753
  text_parts: list[str] = []
@@ -697,7 +870,12 @@ def responses_completion_request(
697
870
  reason = raw_reason.strip()
698
871
  if text:
699
872
  return f"{text}\n\n[Response incomplete: {reason}]"
700
- raise LLMError(f"Responses API response was incomplete: {reason}.")
873
+ raise LLMError(
874
+ format_incomplete_response_error(
875
+ reason=reason,
876
+ final_response=final_response,
877
+ )
878
+ )
701
879
 
702
880
  if text:
703
881
  return text
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
+ from urllib.parse import urlparse
4
5
 
5
6
  from rich.console import Console
6
7
  from rich.panel import Panel
@@ -9,6 +10,9 @@ from rich.table import Table
9
10
  from . import core as llm
10
11
 
11
12
  DEFAULT_SUPPORTED_ENDPOINTS = ("/chat/completions",)
13
+ CHAT_COMPLETIONS_ENDPOINT = "/chat/completions"
14
+ RESPONSES_ENDPOINT = "/responses"
15
+ MODELS_ENDPOINT = "/models"
12
16
 
13
17
  console = Console()
14
18
 
@@ -32,16 +36,51 @@ def request_headers(
32
36
  return headers
33
37
 
34
38
 
39
+ def endpoint_kind(url: str) -> str | None:
40
+ path = urlparse(url).path.rstrip("/")
41
+ if path.endswith(CHAT_COMPLETIONS_ENDPOINT):
42
+ return "chat_completions"
43
+ if path.endswith(RESPONSES_ENDPOINT):
44
+ return "responses"
45
+ if path.endswith(MODELS_ENDPOINT):
46
+ return "models"
47
+ return None
48
+
49
+
50
+ def supported_endpoints_for_url(url: str) -> tuple[str, ...]:
51
+ kind = endpoint_kind(url)
52
+ if kind == "chat_completions":
53
+ return (CHAT_COMPLETIONS_ENDPOINT,)
54
+ if kind == "responses":
55
+ return (RESPONSES_ENDPOINT,)
56
+ return DEFAULT_SUPPORTED_ENDPOINTS
57
+
58
+
59
+ def completion_api_surface_from_url(url: str) -> str:
60
+ kind = endpoint_kind(url)
61
+ if kind in {"chat_completions", "responses"}:
62
+ return kind
63
+ raise LLMError(
64
+ "OpenAI-compatible generation URL must end with "
65
+ f"`{CHAT_COMPLETIONS_ENDPOINT}` or `{RESPONSES_ENDPOINT}`."
66
+ )
67
+
68
+
35
69
  def list_models(
36
70
  client,
37
71
  *,
38
72
  base_url: str,
39
73
  api_key: str | None = None,
40
74
  ) -> list[Model]:
75
+ if endpoint_kind(base_url) != "models":
76
+ raise LLMError(
77
+ f"OpenAI-compatible models URL must end with `{MODELS_ENDPOINT}`."
78
+ )
79
+
41
80
  payload = llm.request_json(
42
81
  client,
43
82
  "GET",
44
- f"{base_url}/models",
83
+ base_url,
45
84
  headers=request_headers(api_key),
46
85
  )
47
86
 
@@ -95,10 +134,22 @@ def ensure_model_ready(
95
134
  http_client_config: HttpClientConfig | None = None,
96
135
  ) -> Model:
97
136
  if model is not None:
98
- return default_model(model)
137
+ return default_model(
138
+ model,
139
+ supported_endpoints=supported_endpoints_for_url(base_url),
140
+ )
99
141
 
100
142
  if default_model_id is not None:
101
- return default_model(default_model_id)
143
+ return default_model(
144
+ default_model_id,
145
+ supported_endpoints=supported_endpoints_for_url(base_url),
146
+ )
147
+
148
+ if endpoint_kind(base_url) != "models":
149
+ raise LLMError(
150
+ "OpenAI-compatible provider cannot choose a model automatically from "
151
+ "a generation URL. Pass `--model` or configure a default model."
152
+ )
102
153
 
103
154
  with llm.make_http_client(http_client_config) as client:
104
155
  models = list_models(client, base_url=base_url, api_key=api_key)
@@ -120,7 +171,10 @@ def ask(
120
171
  configured_default_model_path: Path | None = None,
121
172
  provider_label: str = "OpenAI-compatible provider",
122
173
  http_client_config: HttpClientConfig | None = None,
174
+ disable_thinking: bool = False,
175
+ max_tokens: int | None = None,
123
176
  ) -> str:
177
+ api_surface = completion_api_surface_from_url(base_url)
124
178
  selected_model = ensure_model_ready(
125
179
  base_url=base_url,
126
180
  api_key=api_key,
@@ -131,23 +185,26 @@ def ask(
131
185
  http_client_config=http_client_config,
132
186
  )
133
187
 
134
- api_surface = llm.infer_api_surface(selected_model)
135
188
  with llm.make_http_client(http_client_config) as client:
136
189
  if api_surface == "responses":
137
190
  return llm.responses_completion_request(
138
191
  client,
139
- f"{base_url}/responses",
192
+ base_url,
140
193
  request_headers(api_key, accept="text/event-stream"),
141
194
  model_id=selected_model.id,
142
195
  prompt=prompt,
196
+ disable_thinking=disable_thinking,
197
+ max_tokens=max_tokens,
143
198
  )
144
199
 
145
200
  return llm.chat_completion_request(
146
201
  client,
147
- f"{base_url}/chat/completions",
202
+ base_url,
148
203
  request_headers(api_key),
149
204
  model_id=selected_model.id,
150
205
  prompt=prompt,
206
+ disable_thinking=disable_thinking,
207
+ max_tokens=max_tokens,
151
208
  )
152
209
 
153
210
 
@@ -212,7 +269,10 @@ def show_summary(
212
269
  f"{selected_model.id} ({llm.infer_api_surface(selected_model)})",
213
270
  )
214
271
  elif default_model_id is not None:
215
- table.add_row("Default model", f"{default_model_id} (chat_completions)")
272
+ table.add_row(
273
+ "Default model",
274
+ f"{default_model_id} ({endpoint_kind(base_url) or 'unknown endpoint'})",
275
+ )
216
276
 
217
277
  console.print(Panel.fit(table, title="LLM Summary"))
218
278
  if warning is not None:
@@ -79,16 +79,11 @@ def normalize_openai_base_url(value: str | None) -> str | None:
79
79
  return None
80
80
 
81
81
  normalized = normalized.rstrip("/")
82
- for suffix in ("/chat/completions", "/responses", "/models"):
83
- if normalized.endswith(suffix):
84
- normalized = normalized[: -len(suffix)]
85
- break
86
-
87
82
  parsed = urlparse(normalized)
88
83
  if not parsed.scheme or not parsed.netloc:
89
84
  raise llm.LLMError(
90
- "OpenAI-compatible base URL must include a scheme and host, for example "
91
- "`http://127.0.0.1:11434/v1`."
85
+ "OpenAI-compatible URL must include a scheme and host, for example "
86
+ "`http://127.0.0.1:11434/v1/chat/completions`."
92
87
  )
93
88
 
94
89
  return normalized.rstrip("/")
@@ -227,8 +222,8 @@ def resolve_provider_config(
227
222
  resolved_base_url = normalize_openai_base_url(os.getenv("OPENAI_BASE_URL"))
228
223
  if resolved_base_url is None:
229
224
  raise llm.LLMError(
230
- "OpenAI-compatible provider requires a base URL. Pass "
231
- "`--base-url http://127.0.0.1:11434/v1` or set "
225
+ "OpenAI-compatible provider requires an endpoint URL. Pass "
226
+ "`--base-url http://127.0.0.1:11434/v1/chat/completions` or set "
232
227
  "`GIT_COPILOT_COMMIT_BASE_URL`."
233
228
  )
234
229
 
@@ -316,6 +311,8 @@ def ask(
316
311
  provider_config: ProviderConfig | None = None,
317
312
  model: str | None = None,
318
313
  http_client_config: llm.HttpClientConfig | None = None,
314
+ disable_thinking: bool = False,
315
+ max_tokens: int | None = None,
319
316
  ) -> str:
320
317
  resolved_provider = provider_config or resolve_provider_config()
321
318
  default_model, config_file = load_default_model()
@@ -327,6 +324,8 @@ def ask(
327
324
  default_model=default_model,
328
325
  configured_default_model_path=config_file,
329
326
  http_client_config=http_client_config,
327
+ disable_thinking=disable_thinking,
328
+ max_tokens=max_tokens,
330
329
  )
331
330
 
332
331
  if resolved_provider.base_url is None:
@@ -341,6 +340,8 @@ def ask(
341
340
  configured_default_model_path=config_file,
342
341
  provider_label=resolved_provider.display_name,
343
342
  http_client_config=http_client_config,
343
+ disable_thinking=disable_thinking,
344
+ max_tokens=max_tokens,
344
345
  )
345
346
 
346
347
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Automatically generate and commit changes using GitHub Copilot or OpenAI-compatible LLMs
5
5
  Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -18,14 +18,16 @@ Description-Content-Type: text/markdown
18
18
  [![PyPI](https://img.shields.io/pypi/v/git-copilot-commit)](https://pypi.org/project/git-copilot-commit/)
19
19
  [![License](https://img.shields.io/github/license/kdheepak/git-copilot-commit)](https://github.com/kdheepak/git-copilot-commit/blob/main/LICENSE)
20
20
 
21
- AI-powered Git commit assistant that generates conventional commit messages using GitHub Copilot or any OpenAI-compatible LLM.
21
+ AI-powered Git commit assistant that generates conventional commit messages using GitHub Copilot or
22
+ any OpenAI-compatible LLM.
22
23
 
23
24
  ![Screenshot of git-copilot-commit in action](https://github.com/user-attachments/assets/6a6d70a6-6060-44e6-8cf4-a6532e9e9142)
24
25
 
25
26
  ## Features
26
27
 
27
28
  - Generates commit messages based on your staged changes
28
- - Supports GitHub Copilot and OpenAI-compatible `/v1/models` + `/v1/chat/completions` APIs
29
+ - Supports GitHub Copilot and OpenAI-compatible `/v1/chat/completions`, `/v1/responses`,
30
+ and `/v1/models` endpoints
29
31
  - Supports multiple LLM models: GPT, Claude, Gemini, local models, and more
30
32
  - Allows editing of generated messages before committing
31
33
  - Follows the [Conventional Commits](https://www.conventionalcommits.org/) standard
@@ -51,8 +53,8 @@ You can run the latest version of tool directly every time by invoking this one
51
53
  uvx git-copilot-commit --help
52
54
  ```
53
55
 
54
- Alternatively, you can install the tool once into a global isolated environment
55
- and run `git-copilot-commit` to invoke it:
56
+ Alternatively, you can install the tool once into a global isolated environment and run
57
+ `git-copilot-commit` to invoke it:
56
58
 
57
59
  ```bash
58
60
  # Install into global isolated environment
@@ -96,39 +98,27 @@ git-copilot-commit --help
96
98
 
97
99
  ### OpenAI-compatible provider
98
100
 
99
- 1. Point the CLI at your server.
100
-
101
- The base URL can be either the provider root such as `http://127.0.0.1:11434/v1`
102
- or the full chat completions endpoint such as
103
- `http://127.0.0.1:11434/v1/chat/completions`.
101
+ 1. List models by pointing the CLI at your server's `/models` endpoint.
104
102
 
105
103
  ```bash
106
104
  uvx git-copilot-commit models \
107
105
  --provider openai \
108
- --base-url http://127.0.0.1:11434/v1
106
+ --base-url http://127.0.0.1:11434/v1/models
109
107
  ```
110
108
 
111
- 2. Generate and commit.
109
+ 2. Generate and commit by pointing the CLI at the generation endpoint you want to use.
112
110
 
113
111
  ```bash
114
112
  uvx git-copilot-commit commit \
115
113
  --provider openai \
116
- --base-url http://127.0.0.1:11434/v1 \
114
+ --base-url http://127.0.0.1:11434/v1/chat/completions \
117
115
  --model your-model-id
118
116
  ```
119
117
 
120
118
  If your server requires an API key, also pass `--api-key ...` or set `OPENAI_API_KEY`.
121
119
 
122
- 3. Example: use a self-hosted GPT-OSS model:
123
-
124
- ```bash
125
- uvx git-copilot-commit commit \
126
- --provider openai \
127
- --base-url http://example.com:8001/v1/chat/completions \
128
- --model openai/gpt-oss-120b
129
- ```
130
-
131
- Model ids with slashes such as `openai/gpt-oss-120b` are supported.
120
+ OpenAI-compatible generation URLs must end with `/chat/completions` or `/responses`.
121
+ Model listing URLs must end with `/models`.
132
122
 
133
123
  ## Usage
134
124
 
@@ -137,28 +127,39 @@ git-copilot-commit --help
137
127
  ```bash
138
128
  $ uvx git-copilot-commit commit --help
139
129
 
140
- Usage: git-copilot-commit commit [OPTIONS]
130
+ Usage: git-copilot-commit commit [ARGS]
141
131
 
142
132
  Generate commit message based on changes in the current git repository and commit them.
143
133
 
144
- ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────╮
145
- │ --all -a Stage all files before committing
146
- │ --split Split staged hunks into multiple commits automatically.
147
- Pass `--split=N` to express a preference for N commits.
148
- --model -m MODEL_ID Model to use for generating commit message
149
- │ --yes -y Automatically accept the generated commit message │
150
- │ --context -c TEXT Optional user-provided context to guide commit message
151
- --provider TEXT LLM provider to use: copilot or openai
152
- │ --base-url URL Base URL for an OpenAI-compatible provider
153
- --api-key TEXT API key for an OpenAI-compatible provider
154
- --ca-bundle PATH Path to a custom CA bundle (PEM)
155
- --insecure Disable SSL certificate verification.
156
- --native-tls --no-native-tls Use the OS's native certificate store via 'truststore'
157
- for httpx instead of the Python bundle. Ignored if
158
- --ca-bundle or --insecure is used.
159
- [default: no-native-tls]
160
- │ --help Show this message and exit.
161
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯
134
+ ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
135
+ ALL --all -a --no-all Stage all files before committing [default: False]
136
+ SPLIT --split --no-split Split staged hunks into multiple commits
137
+ automatically. Pass --split=N to express a
138
+ preference for N commits. [default: False]
139
+ MODEL --model -m Model to use for generating commit message │
140
+ YES --yes -y --no-yes Automatically accept the generated commit message
141
+ [default: False]
142
+ CONTEXT --context -c Optional user-provided context to guide commit
143
+ message [default: ""]
144
+ DISABLE-THINKING Disable or minimize reasoning/thinking tokens for
145
+ --disable-thinking commit-message requests. [default: True]
146
+ --enable-thinking
147
+ MAX-TOKENS --max-tokens Maximum output tokens for LLM generation.
148
+ [default: 1024]
149
+ PROVIDER --provider LLM provider to use: copilot or openai.
150
+ BASE-URL --base-url Endpoint URL for an OpenAI-compatible provider,
151
+ │ for example │
152
+ │ http://127.0.0.1:11434/v1/chat/completions. │
153
+ │ API-KEY --api-key API key for an OpenAI-compatible provider. Omit │
154
+ │ when the server does not require one. │
155
+ │ CA-BUNDLE --ca-bundle Path to a custom CA bundle (PEM) │
156
+ │ INSECURE --insecure Disable SSL certificate verification. [default: │
157
+ │ --no-insecure False] │
158
+ │ NATIVE-TLS --native-tls Use the OS's native certificate store via │
159
+ │ --no-native-tls 'truststore' for httpx instead of the Python │
160
+ │ bundle. Ignored if --ca-bundle or --insecure is │
161
+ │ used. [default: True] │
162
+ ╰──────────────────────────────────────────────────────────────────────────────╯
162
163
  ```
163
164
 
164
165
  ## Examples
@@ -186,17 +187,51 @@ Use a local OpenAI-compatible server:
186
187
  ```bash
187
188
  uvx git-copilot-commit commit \
188
189
  --provider openai \
189
- --base-url http://127.0.0.1:11434/v1 \
190
+ --base-url http://127.0.0.1:11434/v1/chat/completions \
190
191
  --model your-model-id
191
192
  ```
192
193
 
193
- Use a self-hosted GPT-OSS endpoint:
194
+ Example with `openai/gpt-oss-120b` and `Qwen/Qwen3.6-35B-A3B`:
194
195
 
195
196
  ```bash
196
197
  uvx git-copilot-commit commit \
197
198
  --provider openai \
198
199
  --base-url http://example.com:8001/v1/chat/completions \
199
200
  --model openai/gpt-oss-120b
201
+
202
+ uvx git-copilot-commit commit \
203
+ --provider openai \
204
+ --base-url http://example.com:8002/v1/chat/completions \
205
+ --model Qwen/Qwen3.6-35B-A3B
206
+ ```
207
+
208
+ Use the Responses API endpoint:
209
+
210
+ ```bash
211
+ uvx git-copilot-commit commit \
212
+ --provider openai \
213
+ --base-url http://example.com:8002/v1/responses \
214
+ --model your-model-id
215
+ ```
216
+
217
+ Increase the output token budget:
218
+
219
+ ```bash
220
+ uvx git-copilot-commit commit --max-tokens 4096
221
+ ```
222
+
223
+ Thinking/reasoning is disabled or minimized by default for commit-message requests. To let the
224
+ selected model use its default thinking behavior, pass:
225
+
226
+ ```bash
227
+ uvx git-copilot-commit commit --enable-thinking
228
+ ```
229
+
230
+ TLS uses the operating system's native certificate store by default. To use Python's default
231
+ certificate bundle instead, pass:
232
+
233
+ ```bash
234
+ uvx git-copilot-commit commit --no-native-tls
200
235
  ```
201
236
 
202
237
  Split staged hunks into separate commits:
@@ -246,8 +281,8 @@ Now you can run to review the message before committing:
246
281
  git ai-commit
247
282
  ```
248
283
 
249
- Alternatively, you can stage all files and auto accept the commit message and
250
- specify which model should be used to generate the commit in one CLI invocation.
284
+ Alternatively, you can stage all files and auto accept the commit message and specify which model
285
+ should be used to generate the commit in one CLI invocation.
251
286
 
252
287
  ```bash
253
288
  git ai-commit --all --yes --model claude-3.5-sonnet
@@ -257,7 +292,7 @@ You can also set provider defaults with environment variables:
257
292
 
258
293
  ```bash
259
294
  export GIT_COPILOT_COMMIT_PROVIDER=openai
260
- export GIT_COPILOT_COMMIT_BASE_URL=http://127.0.0.1:11434/v1
295
+ export GIT_COPILOT_COMMIT_BASE_URL=http://127.0.0.1:11434/v1/chat/completions
261
296
  export GIT_COPILOT_COMMIT_API_KEY=...
262
297
  export OPENAI_API_KEY=...
263
298
  git ai-commit --provider openai --model your-model-id
@@ -267,7 +302,7 @@ For example:
267
302
 
268
303
  ```bash
269
304
  export GIT_COPILOT_COMMIT_PROVIDER=openai
270
- export GIT_COPILOT_COMMIT_BASE_URL=http://example.com:8001/v1
305
+ export GIT_COPILOT_COMMIT_BASE_URL=http://example.com:8001/v1/chat/completions
271
306
  git ai-commit --model openai/gpt-oss-120b
272
307
  ```
273
308
 
@@ -279,5 +314,5 @@ git ai-commit --model openai/gpt-oss-120b
279
314
  > git config --global diff.context 3
280
315
  > ```
281
316
  >
282
- > This may be useful because this tool sends the diffs with surrounding context
283
- > to the LLM for generating a commit message
317
+ > This may be useful because this tool sends the diffs with surrounding context to the LLM for
318
+ > generating a commit message
@@ -1,19 +1,19 @@
1
1
  git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
2
- git_copilot_commit/cli.py,sha256=x5p_f71DhnYEED8-9rvJKqkjTbcwfXlN3zIvfxmYQfU,38011
2
+ git_copilot_commit/cli.py,sha256=ZlrXY6c4JUxMqLEkykhnNTgoegOfxLq9Pp7Yqy1PuOk,40391
3
3
  git_copilot_commit/git.py,sha256=EbXiicWygSlMM-F6rY4LCkchwCvsFTziJcdUZM-1vnw,21059
4
4
  git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  git_copilot_commit/settings.py,sha256=WrM10_J3F7QBfOVmPDWpNZrNHhmZSeN-9FqQZxgdWvQ,3730
6
6
  git_copilot_commit/split_commits.py,sha256=rHyuVJggjmYjbva7BVqsM3aZRxUgOKkuZtxxvFRcu6Q,15060
7
7
  git_copilot_commit/version.py,sha256=AieHOUX52g6N67HL0iLWtDKrgOYyulxwHWViu26Jrd4,105
8
8
  git_copilot_commit/llms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- git_copilot_commit/llms/copilot.py,sha256=O2jhdhWhsLdjx0LgU30_JhReyfR9yimfhs_KIfddNSY,25010
10
- git_copilot_commit/llms/core.py,sha256=PGYa2Znsu27juK5lCKcrB_GPY1jpLXfVgLrDnR15JR4,25586
11
- git_copilot_commit/llms/openai_api.py,sha256=wkadrdSDadbLRaLWEpOhsYYjrbtEYg14CFbkXKYQxM0,6429
12
- git_copilot_commit/llms/providers.py,sha256=rA2mdCQR8pfVDhwV5mqpdHlT1nxkWtowQ1Smt0zXCa0,11565
9
+ git_copilot_commit/llms/copilot.py,sha256=_RK4jpjziXeTrpcSA-Kaj1AT-18y4Nztu1Kqdkh95Bs,25415
10
+ git_copilot_commit/llms/core.py,sha256=4JseguAA17RIPi59nXcCYn1DP3duw2CZpjKrZKsLMsk,31182
11
+ git_copilot_commit/llms/openai_api.py,sha256=--6RVwxV2aGsJT2paJzv86CihoPczpfEVFA4P5_kSSg,8325
12
+ git_copilot_commit/llms/providers.py,sha256=N0DLkfRP-Z85kcYYoXWWHXr5nIwXUXDV88skS-NXLpU,11649
13
13
  git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=3Dz8GCdumFNAtXOdTlpRtgBnmX0WyrPL6tdfMgNyYiE,2411
14
14
  git_copilot_commit/prompts/split-commit-planner-prompt.md,sha256=tDI0v1udOhkRQM31M892FMzcPMYHExnU0fjTGia1V2k,1510
15
- git_copilot_commit-0.6.1.dist-info/METADATA,sha256=3pBOX2m_9i-UKx-dQUu5fwr2gA638RePMzEBe8rf4E4,8986
16
- git_copilot_commit-0.6.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
- git_copilot_commit-0.6.1.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
18
- git_copilot_commit-0.6.1.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
19
- git_copilot_commit-0.6.1.dist-info/RECORD,,
15
+ git_copilot_commit-0.7.0.dist-info/METADATA,sha256=lI1E3-S5S1HEsTFOaqPuXwPE0WCF2kNx_mrWZFNReyg,10006
16
+ git_copilot_commit-0.7.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
+ git_copilot_commit-0.7.0.dist-info/entry_points.txt,sha256=-D4bQqiuSPwQJG2zx--vJbZD1iqB5coUfoJ_gmC3rSg,66
18
+ git_copilot_commit-0.7.0.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
19
+ git_copilot_commit-0.7.0.dist-info/RECORD,,