git-copilot-commit 0.4.1__tar.gz → 0.4.2__tar.gz

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.
Files changed (18) hide show
  1. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/PKG-INFO +1 -1
  2. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/github_copilot.py +197 -29
  3. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/.github/workflows/ci.yml +0 -0
  4. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/.gitignore +0 -0
  5. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/.justfile +0 -0
  6. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/.python-version +0 -0
  7. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/LICENSE +0 -0
  8. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/README.md +0 -0
  9. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/pyproject.toml +0 -0
  10. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/__init__.py +0 -0
  11. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/cli.py +0 -0
  12. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/git.py +0 -0
  13. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  14. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/py.typed +0 -0
  15. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/settings.py +0 -0
  16. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/src/git_copilot_commit/version.py +0 -0
  17. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/uv.lock +0 -0
  18. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.2}/vhs/demo.vhs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Automatically generate and commit changes using copilot
5
5
  Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -251,6 +251,59 @@ def request_json(
251
251
  raise CopilotError(f"Invalid JSON response from {url}.") from exc
252
252
 
253
253
 
254
+ def iter_sse_events(response: httpx.Response, url: str):
255
+ event_name: str | None = None
256
+ data_lines: list[str] = []
257
+
258
+ def decode_event(raw_data: str, current_event: str | None) -> Any:
259
+ if raw_data == "[DONE]":
260
+ return None
261
+ try:
262
+ payload = json.loads(raw_data)
263
+ except json.JSONDecodeError as exc:
264
+ label = current_event or "message"
265
+ raise CopilotError(
266
+ f"Invalid SSE event payload from {url} ({label})."
267
+ ) from exc
268
+ if isinstance(payload, dict) and current_event and "type" not in payload:
269
+ payload = dict(payload)
270
+ payload["type"] = current_event
271
+ return payload
272
+
273
+ for raw_line in response.iter_lines():
274
+ line = raw_line if isinstance(raw_line, str) else raw_line.decode("utf-8")
275
+ if not line:
276
+ if data_lines:
277
+ current_event = event_name
278
+ raw_data = "\n".join(data_lines)
279
+ event_name = None
280
+ data_lines = []
281
+ payload = decode_event(raw_data, current_event)
282
+ if payload is not None:
283
+ yield payload
284
+ else:
285
+ event_name = None
286
+ continue
287
+
288
+ if line.startswith(":"):
289
+ continue
290
+
291
+ field, _, value = line.partition(":")
292
+ if value.startswith(" "):
293
+ value = value[1:]
294
+
295
+ if field == "event":
296
+ event_name = value
297
+ continue
298
+ if field == "data":
299
+ data_lines.append(value)
300
+
301
+ if data_lines:
302
+ payload = decode_event("\n".join(data_lines), event_name)
303
+ if payload is not None:
304
+ yield payload
305
+
306
+
254
307
  def load_credentials() -> CopilotCredentials | None:
255
308
  path = credentials_path()
256
309
  if not path.exists():
@@ -455,10 +508,11 @@ def copilot_request_headers(
455
508
  access_token: str,
456
509
  *,
457
510
  intent: str = "conversation-panel",
511
+ accept: str = "application/json",
458
512
  ) -> dict[str, str]:
459
513
  return {
460
514
  "Authorization": f"Bearer {access_token}",
461
- "Accept": "application/json",
515
+ "Accept": accept,
462
516
  "Content-Type": "application/json",
463
517
  "User-Agent": USER_AGENT,
464
518
  "Editor-Version": EDITOR_VERSION,
@@ -652,7 +706,7 @@ def chat_completion(
652
706
  }
653
707
  ],
654
708
  "temperature": 0,
655
- "max_tokens": 32,
709
+ "max_tokens": 1024,
656
710
  "stream": False,
657
711
  },
658
712
  )
@@ -672,6 +726,7 @@ def extract_response_text(payload: Any) -> str:
672
726
  raise CopilotError("Responses API returned no output.")
673
727
 
674
728
  parts: list[str] = []
729
+ refusals: list[str] = []
675
730
  for item in output:
676
731
  if not isinstance(item, dict):
677
732
  continue
@@ -684,12 +739,20 @@ def extract_response_text(payload: Any) -> str:
684
739
  text = block.get("text")
685
740
  if isinstance(text, str) and text.strip():
686
741
  parts.append(text.strip())
742
+ continue
743
+ refusal = block.get("refusal")
744
+ if isinstance(refusal, str) and refusal.strip():
745
+ refusals.append(refusal.strip())
687
746
 
688
747
  joined = "\n".join(parts).strip()
689
748
  if joined:
690
749
  return joined
691
750
 
692
- raise CopilotError(f"Responses API output did not contain text: {payload}")
751
+ joined_refusals = "\n".join(refusals).strip()
752
+ if joined_refusals:
753
+ return joined_refusals
754
+
755
+ raise CopilotError("Responses API output did not contain text.")
693
756
 
694
757
 
695
758
  def responses_completion(
@@ -699,32 +762,137 @@ def responses_completion(
699
762
  model: CopilotModel,
700
763
  prompt: str,
701
764
  ) -> str:
702
- payload = request_json(
703
- client,
704
- "POST",
705
- f"{credentials.base_url()}/responses",
706
- headers=copilot_request_headers(
707
- credentials.copilot_token, intent="conversation-edits"
708
- ),
709
- json_body={
710
- "model": model.id,
711
- "input": [
712
- {
713
- "role": "user",
714
- "content": [
715
- {
716
- "type": "input_text",
717
- "text": prompt,
718
- }
719
- ],
720
- }
721
- ],
722
- "stream": False,
723
- "store": False,
724
- "max_output_tokens": 1024,
725
- },
726
- )
727
- return extract_response_text(payload)
765
+ url = f"{credentials.base_url()}/responses"
766
+ request_body = {
767
+ "model": model.id,
768
+ "input": [
769
+ {
770
+ "role": "user",
771
+ "content": [
772
+ {
773
+ "type": "input_text",
774
+ "text": prompt,
775
+ }
776
+ ],
777
+ }
778
+ ],
779
+ "stream": True,
780
+ "store": False,
781
+ }
782
+
783
+ text_parts: list[str] = []
784
+ final_response: dict[str, Any] | None = None
785
+
786
+ try:
787
+ with client.stream(
788
+ "POST",
789
+ url,
790
+ headers=copilot_request_headers(
791
+ credentials.copilot_token,
792
+ intent="conversation-edits",
793
+ accept="text/event-stream",
794
+ ),
795
+ json=request_body,
796
+ ) as response:
797
+ if response.is_error:
798
+ detail = response.read().decode("utf-8", errors="replace").strip()
799
+ if len(detail) > 400:
800
+ detail = f"{detail[:397]}..."
801
+ suffix = f": {detail}" if detail else ""
802
+ raise CopilotError(
803
+ f"{response.status_code} {response.reason_phrase}{suffix}"
804
+ )
805
+
806
+ content_type = response.headers.get("content-type", "")
807
+ if "text/event-stream" not in content_type:
808
+ body = response.read()
809
+ try:
810
+ payload = json.loads(body)
811
+ except ValueError as exc:
812
+ detail = body.decode("utf-8", errors="replace").strip()
813
+ if len(detail) > 400:
814
+ detail = f"{detail[:397]}..."
815
+ raise CopilotError(
816
+ f"Expected an SSE stream from {url}, got {content_type or 'unknown content type'}: {detail}"
817
+ ) from exc
818
+ return extract_response_text(payload)
819
+
820
+ for event in iter_sse_events(response, url):
821
+ if not isinstance(event, dict):
822
+ continue
823
+
824
+ event_type = event.get("type")
825
+ if event_type == "response.output_text.delta":
826
+ delta = event.get("delta")
827
+ if isinstance(delta, str) and delta:
828
+ text_parts.append(delta)
829
+ continue
830
+
831
+ if event_type == "response.output_text.done" and not text_parts:
832
+ text = event.get("text")
833
+ if isinstance(text, str) and text.strip():
834
+ text_parts.append(text)
835
+ continue
836
+
837
+ if event_type == "error":
838
+ error = event.get("error")
839
+ if isinstance(error, dict):
840
+ message = error.get("message")
841
+ code = error.get("code")
842
+ if isinstance(message, str) and message.strip():
843
+ prefix = (
844
+ f"{code}: " if isinstance(code, str) and code else ""
845
+ )
846
+ raise CopilotError(
847
+ f"Responses stream error: {prefix}{message.strip()}"
848
+ )
849
+ raise CopilotError("Responses stream returned an error event.")
850
+
851
+ if event_type in {
852
+ "response.completed",
853
+ "response.failed",
854
+ "response.incomplete",
855
+ }:
856
+ response_payload = event.get("response")
857
+ if isinstance(response_payload, dict):
858
+ final_response = response_payload
859
+ except httpx.HTTPError as exc:
860
+ raise CopilotError(f"Request failed for {url}: {exc}") from exc
861
+
862
+ text = "".join(text_parts).strip()
863
+ if final_response is None:
864
+ if text:
865
+ return text
866
+ raise CopilotError("Responses stream ended without a terminal response event.")
867
+
868
+ status = final_response.get("status")
869
+ if status == "failed":
870
+ error = final_response.get("error")
871
+ if isinstance(error, dict):
872
+ message = error.get("message")
873
+ code = error.get("code")
874
+ if isinstance(message, str) and message.strip():
875
+ prefix = f"{code}: " if isinstance(code, str) and code else ""
876
+ raise CopilotError(
877
+ f"Responses API request failed: {prefix}{message.strip()}"
878
+ )
879
+ raise CopilotError("Responses API request failed.")
880
+
881
+ if status == "incomplete":
882
+ details = final_response.get("incomplete_details")
883
+ reason = "unknown"
884
+ if isinstance(details, dict):
885
+ raw_reason = details.get("reason")
886
+ if isinstance(raw_reason, str) and raw_reason.strip():
887
+ reason = raw_reason.strip()
888
+ if text:
889
+ return f"{text}\n\n[Response incomplete: {reason}]"
890
+ raise CopilotError(f"Responses API response was incomplete: {reason}.")
891
+
892
+ if text:
893
+ return text
894
+
895
+ return extract_response_text(final_response)
728
896
 
729
897
 
730
898
  def complete_text_prompt(