gptdiff 0.1.20__tar.gz → 0.1.22__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gptdiff
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: A tool to generate and apply git diffs using LLMs
5
5
  Author: 255labs
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -10,7 +10,7 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE.txt
11
11
  Requires-Dist: openai>=1.0.0
12
12
  Requires-Dist: tiktoken>=0.5.0
13
- Requires-Dist: ai_agent_toolbox>=0.1.13
13
+ Requires-Dist: ai-agent-toolbox>=0.1.15
14
14
  Provides-Extra: test
15
15
  Requires-Dist: pytest; extra == "test"
16
16
  Requires-Dist: pytest-mock; extra == "test"
@@ -345,7 +345,8 @@ def smartapply(diff_text, files, model=None, api_key=None, base_url=None):
345
345
  del files[path]
346
346
  else:
347
347
  updated = call_llm_for_apply_with_think_tool_available(path, original, patch, model, api_key=api_key, base_url=base_url)
348
- files[path] = updated.strip()
348
+ cleaned = strip_bad_output(updated, original)
349
+ files[path] = cleaned
349
350
 
350
351
  threads = []
351
352
 
@@ -585,7 +586,7 @@ def parse_diff_per_file(diff_text):
585
586
  for line in lines:
586
587
  if header_line_re.match(line):
587
588
  if current_file is not None and current_lines:
588
- if deletion_mode and not any(l.startswith("+++ ") for l in current_lines):
589
+ if deletion_mode and not any(l.startswith("+++ /dev/null") for l in current_lines):
589
590
  current_lines.append("+++ /dev/null")
590
591
  diffs.append((current_file, "\n".join(current_lines)))
591
592
  current_lines = [line]
@@ -778,24 +779,41 @@ def smart_apply_patch(project_dir, diff_text, user_prompt, args):
778
779
  else:
779
780
  print(f"\033[1;33mFile {file_path} not found - skipping deletion\033[0m")
780
781
  return
781
- original_content = ''
782
- if full_path.exists():
783
- try:
784
- original_content = full_path.read_text()
785
- except UnicodeDecodeError:
786
- print(f"Skipping binary file {file_path}")
787
- return
788
- if not hasattr(args, "applymodel") or args.applymodel is None:
789
- args.applymodel = args.model
790
- if args.applymodel is None:
791
- args.applymodel = os.getenv("GPTDIFF_MODEL")
782
+
783
+ try:
784
+ original_content = full_path.read_text()
785
+ except (UnicodeDecodeError, IOError):
786
+ print(f"Skipping file {file_path} due to read error")
787
+ return
788
+
789
+ # Use SMARTAPPLY-specific environment variables if set, otherwise fallback.
790
+ smart_apply_model = os.getenv("GPTDIFF_SMARTAPPLY_MODEL")
791
+ if smart_apply_model and smart_apply_model.strip():
792
+ model = smart_apply_model
793
+ elif hasattr(args, "applymodel") and args.applymodel:
794
+ model = args.applymodel
795
+ else:
796
+ model = os.getenv("GPTDIFF_MODEL", "deepseek-reasoner")
797
+
798
+ smart_api_key = os.getenv("GPTDIFF_SMARTAPPLY_API_KEY")
799
+ if smart_api_key and smart_api_key.strip():
800
+ api_key = smart_api_key
801
+ else:
802
+ api_key = os.getenv("GPTDIFF_LLM_API_KEY")
803
+
804
+ smart_base_url = os.getenv("GPTDIFF_SMARTAPPLY_BASE_URL")
805
+ if smart_base_url and smart_base_url.strip():
806
+ base_url = smart_base_url
807
+ else:
808
+ base_url = os.getenv("GPTDIFF_LLM_BASE_URL", "https://nano-gpt.com/api/v1/")
792
809
 
793
810
  print("-" * 40)
794
- print("Running smartapply with", args.applymodel,"on",file_path)
811
+ print("Running smartapply with", model, "on", file_path)
795
812
  print("-" * 40)
796
813
  try:
797
814
  updated_content = call_llm_for_apply_with_think_tool_available(
798
- file_path, original_content, file_diff, args.applymodel,
815
+ file_path, original_content, file_diff, model,
816
+ api_key=api_key, base_url=base_url,
799
817
  extra_prompt=f"This changeset is from the following instructions:\n{user_prompt}",
800
818
  max_tokens=args.max_tokens)
801
819
  if updated_content.strip() == "":
@@ -985,17 +1003,47 @@ def swallow_reasoning(full_response: str) -> (str, str):
985
1003
  - reasoning: The extracted reasoning block, or an empty string if not found.
986
1004
  """
987
1005
  pattern = re.compile(
988
- r"(?P<reasoning>>\s*Reasoning.*?Reasoned for \d+\s*seconds)",
1006
+ r"(?P<reasoning>>\s*Reasoning.*?Reasoned.*?seconds)",
989
1007
  re.DOTALL
990
1008
  )
991
1009
  match = pattern.search(full_response)
992
1010
  if match:
993
- reasoning = match.group("reasoning").strip()
994
- final_content = full_response.replace(reasoning, "").strip()
1011
+ raw_reasoning = match.group("reasoning")
1012
+ # Remove any leading '+' characters and extra whitespace from each line
1013
+ reasoning_lines = [line.lstrip('+').strip() for line in raw_reasoning.splitlines()]
1014
+ reasoning = "\n".join(reasoning_lines).strip()
1015
+
1016
+ # Remove the reasoning block from the response using its exact span
1017
+ final_content = full_response[:match.start()] + full_response[match.end():]
1018
+ final_content = final_content.strip()
995
1019
  else:
996
1020
  reasoning = ""
997
1021
  final_content = full_response.strip()
998
1022
  return final_content, reasoning
999
1023
 
1024
+ def strip_bad_output(updated: str, original: str) -> str:
1025
+ """
1026
+ If the original file content does not start with a code fence but the LLM’s updated output
1027
+ starts with triple backticks (possibly with an introductory message), extract and return only
1028
+ the content within the first code block.
1029
+ """
1030
+ updated_stripped = updated.strip()
1031
+ # If the original file does not start with a code fence, but the updated output contains a code block,
1032
+ # extract and return only the content inside the first code block.
1033
+ if not original.lstrip().startswith("```"):
1034
+ # Search for the first code block in the updated output.
1035
+ m = re.search(r"```(.*?)```", updated_stripped, re.DOTALL)
1036
+ if m:
1037
+ content = m.group(1).strip()
1038
+ lines = content.splitlines()
1039
+ if len(lines) > 1:
1040
+ first_line = lines[0].strip()
1041
+ # If the first line appears to be a language specifier (i.e., a single word)
1042
+ # and is not "diff", then drop it.
1043
+ if " " not in first_line and first_line.lower() != "diff":
1044
+ content = "\n".join(lines[1:]).strip()
1045
+ return content
1046
+ return updated_stripped
1047
+
1000
1048
  if __name__ == "__main__":
1001
1049
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gptdiff
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: A tool to generate and apply git diffs using LLMs
5
5
  Author: 255labs
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -10,7 +10,7 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE.txt
11
11
  Requires-Dist: openai>=1.0.0
12
12
  Requires-Dist: tiktoken>=0.5.0
13
- Requires-Dist: ai_agent_toolbox>=0.1.13
13
+ Requires-Dist: ai-agent-toolbox>=0.1.15
14
14
  Provides-Extra: test
15
15
  Requires-Dist: pytest; extra == "test"
16
16
  Requires-Dist: pytest-mock; extra == "test"
@@ -16,4 +16,5 @@ tests/test_diff_parse.py
16
16
  tests/test_failing_case.py
17
17
  tests/test_parse_diff_per_file.py
18
18
  tests/test_smartapply.py
19
+ tests/test_strip_bad_ouput.py
19
20
  tests/test_swallow_reasoning.py
@@ -1,6 +1,6 @@
1
1
  openai>=1.0.0
2
2
  tiktoken>=0.5.0
3
- ai_agent_toolbox>=0.1.13
3
+ ai-agent-toolbox>=0.1.15
4
4
 
5
5
  [docs]
6
6
  mkdocs
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='gptdiff',
5
- version='0.1.20',
5
+ version='0.1.22',
6
6
  description='A tool to generate and apply git diffs using LLMs',
7
7
  author='255labs',
8
8
  packages=find_packages(), # Use find_packages() to automatically discover packages
@@ -12,7 +12,7 @@ setup(
12
12
  install_requires=[
13
13
  'openai>=1.0.0',
14
14
  'tiktoken>=0.5.0',
15
- 'ai_agent_toolbox>=0.1.13'
15
+ 'ai-agent-toolbox>=0.1.15'
16
16
  ],
17
17
  extras_require={
18
18
  'test': ['pytest', 'pytest-mock'],
@@ -0,0 +1,90 @@
1
+ # File: tests/test_strip_bad_output.py
2
+ import pytest
3
+ from gptdiff.gptdiff import strip_bad_output
4
+
5
+ def test_strip_bad_output_removes_wrapping():
6
+ """
7
+ If the original file content does not start with a code fence,
8
+ but the LLM output starts with a code block and extra text,
9
+ then only the content inside the first code block should be returned.
10
+ """
11
+ # Original file content does not start with a code fence.
12
+ original = "def hello():\n print('Hello')\n"
13
+ # Simulated LLM output with extraneous text and a code block.
14
+ updated = (
15
+ "This is the file you requested:\n"
16
+ "```diff\n"
17
+ "def hello():\n"
18
+ " print('Goodbye')\n"
19
+ "```\n"
20
+ "Thank you!"
21
+ )
22
+ # We expect the function to extract only the content inside the code block.
23
+ expected = "diff\ndef hello():\n print('Goodbye')"
24
+ result = strip_bad_output(updated, original)
25
+ assert result == expected, f"Expected:\n{expected}\nGot:\n{result}"
26
+
27
+ def test_strip_bad_output_no_change_when_original_has_code_block():
28
+ """
29
+ If the original file already starts with a code fence,
30
+ the function should leave the updated output unchanged.
31
+ """
32
+ original = "```diff\ndef hello():\n print('Hello')\n```"
33
+ updated = "```diff\ndef hello():\n print('Modified')\n```"
34
+ expected = updated.strip()
35
+ result = strip_bad_output(updated, original)
36
+ assert result == expected, "Expected no changes when original already starts with a code fence"
37
+
38
+ def test_strip_bad_output_no_wrapping_detected():
39
+ """
40
+ If the updated output does not start with a code fence,
41
+ the function should return the updated output unchanged.
42
+ """
43
+ original = "def hello():\n print('Hello')\n"
44
+ updated = "def hello():\n print('Modified')\n"
45
+ expected = updated.strip()
46
+ result = strip_bad_output(updated, original)
47
+ assert result == expected, "Expected output to remain unchanged if no code block is detected"
48
+
49
+
50
+ def test_strip_bad_output_prod_case():
51
+ """
52
+ Test that when the updated output includes extraneous introductory text and
53
+ a language specifier in the code block, the function extracts only the content
54
+ within the code block (without the language tag or extra text).
55
+
56
+ For example, given an updated output like:
57
+
58
+ Here's the entire file after applying the diff:
59
+
60
+ ```typescript
61
+ def foo():
62
+ print('Modified')
63
+ ```
64
+ Some trailing text that should be ignored.
65
+
66
+ the expected extracted content is:
67
+
68
+ def foo():
69
+ print('Modified')
70
+ """
71
+ # Original file content does not start with a code fence.
72
+ original = "def foo():\n pass\n"
73
+
74
+ # Simulated LLM output with extraneous text, a language specifier ("typescript"),
75
+ # and trailing text.
76
+ updated = (
77
+ "Here's the entire file after applying the diff:\n\n"
78
+ "```typescript\n"
79
+ "def foo():\n"
80
+ " print('Modified')\n"
81
+ "```\n"
82
+ "Some trailing text that should be ignored."
83
+ )
84
+
85
+ # We expect the function to extract only the content inside the first code block,
86
+ # ignoring the language specifier and any text outside the code block.
87
+ expected = "def foo():\n print('Modified')"
88
+
89
+ result = strip_bad_output(updated, original)
90
+ assert result == expected, f"Expected:\n{expected}\nGot:\n{result}"
@@ -0,0 +1,156 @@
1
+ import pytest
2
+
3
+ from gptdiff.gptdiff import swallow_reasoning
4
+
5
+ def test_swallow_reasoning_extraction():
6
+ llm_response = (
7
+ "+> Reasoning\n"
8
+ "+None\n"
9
+ "+Reasoned about summary drawer button 변경 for 15 seconds\n"
10
+ "+def new():\n"
11
+ "```"
12
+ )
13
+ final_content, reasoning = swallow_reasoning(llm_response)
14
+ expected_reasoning = (
15
+ "> Reasoning\n"
16
+ "**Applying the diff**\n"
17
+ "I'm piecing together how to efficiently apply a diff to a file...\n"
18
+ "**Returning the result**\n"
19
+ "I'm finalizing the method to apply the diff updates...\n"
20
+ "Reasoned for 6 seconds"
21
+ )
22
+ assert reasoning == expected_reasoning
23
+ # The final content should no longer contain the reasoning block.
24
+ assert expected_reasoning not in final_content
25
+ # And it should contain the diff block.
26
+ assert "```diff" in final_content
27
+
28
+
29
+ def test_swallow_reasoning_with_untested_response():
30
+ llm_response = (
31
+ "> Reasoning\n"
32
+ "**Considering the request**\n"
33
+ "I’m noting that the user wants me to apply a diff to a file and return the result in a block, ensuring the entire file is included.\n"
34
+ "**Ensuring comprehensive inclusion**\n"
35
+ "I'm making sure the entire file is included when presenting the result in a block, following the user's request carefully.\n"
36
+ "**Ensuring clarity**\n"
37
+ "I’m integrating the diff into the file and ensuring the entire file is returned as requested. This approach maintains precision and clarity in the response.\n"
38
+ "**Refining the response**\n"
39
+ "I’m focusing on how to structure the response by carefully integrating the diff and ensuring the entire file is included in a clear block format.\n"
40
+ "**Connecting the pieces**\n"
41
+ "I'm mapping out how to apply the diff to the file carefully and ensure the entire file is incorporated into the final block.\n"
42
+ "Reasoned for a few seconds\n"
43
+ "\n"
44
+ "```diff\n"
45
+ "--- a/file.py\n"
46
+ "+++ b/file.py\n"
47
+ "@@ -1,2 +1,2 @@\n"
48
+ "-def old():\n"
49
+ "+def new():\n"
50
+ "```"
51
+ )
52
+ final_content, reasoning = swallow_reasoning(llm_response)
53
+
54
+ expected_reasoning = (
55
+ "> Reasoning\n"
56
+ "**Considering the request**\n"
57
+ "I’m noting that the user wants me to apply a diff to a file and return the result in a block, ensuring the entire file is included.\n"
58
+ "**Ensuring comprehensive inclusion**\n"
59
+ "I'm making sure the entire file is included when presenting the result in a block, following the user's request carefully.\n"
60
+ "**Ensuring clarity**\n"
61
+ "I’m integrating the diff into the file and ensuring the entire file is returned as requested. This approach maintains precision and clarity in the response.\n"
62
+ "**Refining the response**\n"
63
+ "I’m focusing on how to structure the response by carefully integrating the diff and ensuring the entire file is included in a clear block format.\n"
64
+ "**Connecting the pieces**\n"
65
+ "I'm mapping out how to apply the diff to the file carefully and ensure the entire file is incorporated into the final block.\n"
66
+ "Reasoned for a few seconds"
67
+ )
68
+
69
+ assert reasoning == expected_reasoning
70
+ # The final content should no longer contain the reasoning block.
71
+ assert expected_reasoning not in final_content
72
+ # And it should contain the diff block.
73
+ assert "```diff" in final_content
74
+
75
+ def test_swallow_reasoning_extraction():
76
+ llm_response = (
77
+ "> Reasoning\n"
78
+ "**Applying the diff**\n"
79
+ "I'm piecing together how to efficiently apply a diff to a file...\n"
80
+ "**Returning the result**\n"
81
+ "I'm finalizing the method to apply the diff updates...\n"
82
+ "Reasoned for 6 seconds\n"
83
+ "\n"
84
+ "```diff\n"
85
+ "--- a/file.py\n"
86
+ "+++ b/file.py\n"
87
+ "@@ -1,2 +1,2 @@\n"
88
+ "-def old():\n"
89
+ "+def new():\n"
90
+ "```"
91
+ )
92
+ final_content, reasoning = swallow_reasoning(llm_response)
93
+ expected_reasoning = (
94
+ "> Reasoning\n"
95
+ "**Applying the diff**\n"
96
+ "I'm piecing together how to efficiently apply a diff to a file...\n"
97
+ "**Returning the result**\n"
98
+ "I'm finalizing the method to apply the diff updates...\n"
99
+ "Reasoned for 6 seconds"
100
+ )
101
+ assert reasoning == expected_reasoning
102
+ # The final content should no longer contain the reasoning block.
103
+ assert expected_reasoning not in final_content
104
+ # And it should contain the diff block.
105
+ assert "```diff" in final_content
106
+
107
+
108
+ def test_swallow_reasoning_no_reasoning():
109
+ llm_response = (
110
+ "```diff\n"
111
+ "--- a/file.py\n"
112
+ "+++ b/file.py\n"
113
+ "@@ -1,2 +1,2 @@\n"
114
+ "-def old():\n"
115
+ "+def new():\n"
116
+ "```"
117
+ )
118
+ final_content, reasoning = swallow_reasoning(llm_response)
119
+ assert reasoning == ""
120
+ assert final_content == llm_response.strip()
121
+
122
+ def test_swallow_reasoning_inline_newlines():
123
+ llm_response = (
124
+ "Prefix text before reasoning and some inline content "
125
+ "> Reasoning\n"
126
+ "Inline line 1\n"
127
+ "Inline line 2\n"
128
+ "Reasoned for 2 seconds "
129
+ "and then suffix text.\n"
130
+ "```diff\n"
131
+ "--- a/inline.py\n"
132
+ "+++ b/inline.py\n"
133
+ "@@ -1,2 +1,2 @@\n"
134
+ "-print('Old')\n"
135
+ "+print('New')\n"
136
+ "```"
137
+ )
138
+ final_content, reasoning = swallow_reasoning(llm_response)
139
+ expected_reasoning = (
140
+ "> Reasoning\n"
141
+ "Inline line 1\n"
142
+ "Inline line 2\n"
143
+ "Reasoned for 2 seconds"
144
+ )
145
+ # Count the newlines in the extracted reasoning block.
146
+ newline_count = reasoning.count('\n')
147
+ # There should be 3 newline characters: after "> Reasoning", after "Inline line 1", and after "Inline line 2"
148
+ assert newline_count == 3, f"Expected 3 newlines, got {newline_count}"
149
+ assert reasoning == expected_reasoning
150
+ # Ensure the reasoning block is removed from the final content.
151
+ assert expected_reasoning not in final_content
152
+ # Verify that surrounding content remains.
153
+ assert "Prefix text before reasoning" in final_content
154
+ assert "and then suffix text." in final_content
155
+ # Verify that the diff block is still present.
156
+ assert "```diff" in final_content
@@ -1,51 +0,0 @@
1
- import pytest
2
-
3
- from gptdiff.gptdiff import swallow_reasoning
4
-
5
-
6
- def test_swallow_reasoning_extraction():
7
- llm_response = (
8
- "> Reasoning\n"
9
- "**Applying the diff**\n"
10
- "I'm piecing together how to efficiently apply a diff to a file...\n"
11
- "**Returning the result**\n"
12
- "I'm finalizing the method to apply the diff updates...\n"
13
- "Reasoned for 6 seconds\n"
14
- "\n"
15
- "```diff\n"
16
- "--- a/file.py\n"
17
- "+++ b/file.py\n"
18
- "@@ -1,2 +1,2 @@\n"
19
- "-def old():\n"
20
- "+def new():\n"
21
- "```"
22
- )
23
- final_content, reasoning = swallow_reasoning(llm_response)
24
- expected_reasoning = (
25
- "> Reasoning\n"
26
- "**Applying the diff**\n"
27
- "I'm piecing together how to efficiently apply a diff to a file...\n"
28
- "**Returning the result**\n"
29
- "I'm finalizing the method to apply the diff updates...\n"
30
- "Reasoned for 6 seconds"
31
- )
32
- assert reasoning == expected_reasoning
33
- # The final content should no longer contain the reasoning block.
34
- assert expected_reasoning not in final_content
35
- # And it should contain the diff block.
36
- assert "```diff" in final_content
37
-
38
-
39
- def test_swallow_reasoning_no_reasoning():
40
- llm_response = (
41
- "```diff\n"
42
- "--- a/file.py\n"
43
- "+++ b/file.py\n"
44
- "@@ -1,2 +1,2 @@\n"
45
- "-def old():\n"
46
- "+def new():\n"
47
- "```"
48
- )
49
- final_content, reasoning = swallow_reasoning(llm_response)
50
- assert reasoning == ""
51
- assert final_content == llm_response.strip()
File without changes
File without changes
File without changes
File without changes
File without changes