patch-chat 0.1.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.
Files changed (146) hide show
  1. patch/__init__.py +20 -0
  2. patch/__main__.py +4 -0
  3. patch/_version.py +24 -0
  4. patch/analytics.py +260 -0
  5. patch/args.py +955 -0
  6. patch/args_formatter.py +228 -0
  7. patch/coders/__init__.py +32 -0
  8. patch/coders/architect_coder.py +47 -0
  9. patch/coders/architect_prompts.py +40 -0
  10. patch/coders/ask_coder.py +9 -0
  11. patch/coders/ask_prompts.py +41 -0
  12. patch/coders/base_coder.py +2548 -0
  13. patch/coders/base_prompts.py +60 -0
  14. patch/coders/chat_chunks.py +64 -0
  15. patch/coders/context_coder.py +53 -0
  16. patch/coders/context_prompts.py +75 -0
  17. patch/coders/edit_apply.py +6 -0
  18. patch/coders/editblock_coder.py +620 -0
  19. patch/coders/editblock_fenced_coder.py +10 -0
  20. patch/coders/editblock_fenced_prompts.py +143 -0
  21. patch/coders/editblock_func_coder.py +141 -0
  22. patch/coders/editblock_func_prompts.py +27 -0
  23. patch/coders/editblock_prompts.py +172 -0
  24. patch/coders/editor_diff_fenced_coder.py +9 -0
  25. patch/coders/editor_diff_fenced_prompts.py +11 -0
  26. patch/coders/editor_editblock_coder.py +8 -0
  27. patch/coders/editor_editblock_prompts.py +18 -0
  28. patch/coders/editor_whole_coder.py +8 -0
  29. patch/coders/editor_whole_prompts.py +10 -0
  30. patch/coders/patch_coder.py +731 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +419 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1668 -0
  45. patch/copypaste.py +70 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/dump.py +29 -0
  49. patch/editor.py +147 -0
  50. patch/exceptions.py +113 -0
  51. patch/format_settings.py +47 -0
  52. patch/gui.py +545 -0
  53. patch/history.py +143 -0
  54. patch/io.py +1209 -0
  55. patch/linter.py +304 -0
  56. patch/llm.py +47 -0
  57. patch/main.py +1466 -0
  58. patch/mdstream.py +243 -0
  59. patch/models.py +1342 -0
  60. patch/onboarding.py +428 -0
  61. patch/openrouter.py +128 -0
  62. patch/prompts.py +61 -0
  63. patch/queries/tree-sitter-language-pack/README.md +7 -0
  64. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  65. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  66. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  67. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  68. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  69. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  70. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  71. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  72. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  73. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  74. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  75. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  76. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  77. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  78. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  79. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  80. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  81. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  82. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  83. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  84. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  85. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  86. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  87. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  88. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  89. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  90. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  91. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  92. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  93. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  94. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  95. patch/queries/tree-sitter-languages/README.md +24 -0
  96. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  97. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  98. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  99. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  100. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  101. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  102. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  103. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  104. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  105. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  106. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  107. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  108. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  109. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  110. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  111. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  112. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  113. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  114. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  115. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  116. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  117. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  118. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  119. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  120. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  121. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  122. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  123. patch/reasoning_tags.py +82 -0
  124. patch/repo.py +612 -0
  125. patch/repomap.py +859 -0
  126. patch/report.py +200 -0
  127. patch/resources/__init__.py +3 -0
  128. patch/resources/model-metadata.json +715 -0
  129. patch/resources/model-settings.yml +3128 -0
  130. patch/run_cmd.py +146 -0
  131. patch/scrape.py +316 -0
  132. patch/sendchat.py +61 -0
  133. patch/special.py +203 -0
  134. patch/urls.py +17 -0
  135. patch/utils.py +365 -0
  136. patch/versioncheck.py +113 -0
  137. patch/voice.py +200 -0
  138. patch/waiting.py +222 -0
  139. patch/watch.py +318 -0
  140. patch/watch_prompts.py +12 -0
  141. patch_chat-0.1.0.dist-info/METADATA +375 -0
  142. patch_chat-0.1.0.dist-info/RECORD +146 -0
  143. patch_chat-0.1.0.dist-info/WHEEL +5 -0
  144. patch_chat-0.1.0.dist-info/entry_points.txt +2 -0
  145. patch_chat-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  146. patch_chat-0.1.0.dist-info/top_level.txt +1 -0
patch/models.py ADDED
@@ -0,0 +1,1342 @@
1
+ import copy
2
+ import difflib
3
+ import hashlib
4
+ import importlib.resources
5
+ import json
6
+ import math
7
+ import os
8
+ import platform
9
+ import sys
10
+ import time
11
+ from dataclasses import dataclass, fields
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Optional, Union
15
+
16
+ import json5
17
+ import yaml
18
+ from PIL import Image
19
+
20
+ from patch import __version__
21
+ from patch.dump import dump # noqa: F401
22
+ from patch.llm import litellm
23
+ from patch.openrouter import OpenRouterModelManager
24
+ from patch.sendchat import ensure_alternating_roles, sanity_check_messages
25
+ from patch.utils import check_pip_install_extra
26
+
27
+ RETRY_TIMEOUT = 60
28
+
29
+ request_timeout = 600
30
+
31
+ DEFAULT_MODEL_NAME = "gpt-4o"
32
+ ANTHROPIC_BETA_HEADER = "prompt-caching-2024-07-31,pdfs-2024-09-25"
33
+
34
+ OPENAI_MODELS_TEXT = """
35
+ o1
36
+ o1-preview
37
+ o1-mini
38
+ o3-mini
39
+ gpt-4
40
+ gpt-4o
41
+ gpt-4o-2024-05-13
42
+ gpt-4-turbo-preview
43
+ gpt-4-0314
44
+ gpt-4-0613
45
+ gpt-4-32k
46
+ gpt-4-32k-0314
47
+ gpt-4-32k-0613
48
+ gpt-4-turbo
49
+ gpt-4-turbo-2024-04-09
50
+ gpt-4-1106-preview
51
+ gpt-4-0125-preview
52
+ gpt-4-vision-preview
53
+ gpt-4-1106-vision-preview
54
+ gpt-4o-mini
55
+ gpt-4o-mini-2024-07-18
56
+ gpt-5.5
57
+ gpt-5.5-pro
58
+ gpt-5.5-chat-latest
59
+ gpt-3.5-turbo
60
+ gpt-3.5-turbo-0301
61
+ gpt-3.5-turbo-0613
62
+ gpt-3.5-turbo-1106
63
+ gpt-3.5-turbo-0125
64
+ gpt-3.5-turbo-16k
65
+ gpt-3.5-turbo-16k-0613
66
+ """
67
+
68
+ OPENAI_MODELS = [ln.strip() for ln in OPENAI_MODELS_TEXT.splitlines() if ln.strip()]
69
+
70
+ ANTHROPIC_MODELS_TEXT = """
71
+ claude-2
72
+ claude-2.1
73
+ claude-3-haiku-20240307
74
+ claude-3-5-haiku-20241022
75
+ claude-3-opus-20240229
76
+ claude-3-sonnet-20240229
77
+ claude-3-5-sonnet-20240620
78
+ claude-3-5-sonnet-20241022
79
+ claude-3-7-sonnet-20250219
80
+ claude-sonnet-4-20250514
81
+ claude-opus-4-20250514
82
+ claude-opus-4-1
83
+ claude-opus-4-1-20250805
84
+ claude-opus-4-5
85
+ claude-opus-4-5-20251101
86
+ claude-opus-4-6
87
+ claude-opus-4-6-20260205
88
+ claude-opus-4-7
89
+ claude-opus-4-7-20260416
90
+ claude-sonnet-4-5
91
+ claude-sonnet-4-5-20250929
92
+ claude-sonnet-4-6
93
+ claude-haiku-4-5
94
+ claude-haiku-4-5-20251001
95
+ """
96
+
97
+ ANTHROPIC_MODELS = [ln.strip() for ln in ANTHROPIC_MODELS_TEXT.splitlines() if ln.strip()]
98
+
99
+ # Mapping of model aliases to their canonical names
100
+ MODEL_ALIASES = {
101
+ # Claude models
102
+ "sonnet": "claude-sonnet-4-6",
103
+ "haiku": "claude-haiku-4-5",
104
+ "opus": "claude-opus-4-7",
105
+ # GPT models
106
+ "4": "gpt-4-0613",
107
+ "4o": "gpt-4o",
108
+ "4-turbo": "gpt-4-1106-preview",
109
+ "35turbo": "gpt-3.5-turbo",
110
+ "35-turbo": "gpt-3.5-turbo",
111
+ "3": "gpt-3.5-turbo",
112
+ # Other models
113
+ "deepseek": "deepseek/deepseek-chat",
114
+ "flash": "gemini/gemini-flash-latest",
115
+ "flash-lite": "gemini/gemini-2.5-flash-lite",
116
+ "quasar": "openrouter/openrouter/quasar-alpha",
117
+ "r1": "deepseek/deepseek-reasoner",
118
+ "gemini-2.5-pro": "gemini/gemini-2.5-pro",
119
+ "gemini-3-pro-preview": "gemini/gemini-3-pro-preview",
120
+ "gemini": "gemini/gemini-3-pro-preview",
121
+ "gemini-exp": "gemini/gemini-2.5-pro-exp-03-25",
122
+ "grok3": "xai/grok-3-beta",
123
+ "optimus": "openrouter/openrouter/optimus-alpha",
124
+ }
125
+ # Model metadata loaded from resources and user's files.
126
+
127
+
128
+ @dataclass
129
+ class ModelSettings:
130
+ # Model class needs to have each of these as well
131
+ name: str
132
+ edit_format: str = "whole"
133
+ weak_model_name: Optional[str] = None
134
+ use_repo_map: bool = False
135
+ send_undo_reply: bool = False
136
+ lazy: bool = False
137
+ overeager: bool = False
138
+ reminder: str = "user"
139
+ examples_as_sys_msg: bool = False
140
+ extra_params: Optional[dict] = None
141
+ cache_control: bool = False
142
+ caches_by_default: bool = False
143
+ use_system_prompt: bool = True
144
+ use_temperature: Union[bool, float] = True
145
+ streaming: bool = True
146
+ editor_model_name: Optional[str] = None
147
+ editor_edit_format: Optional[str] = None
148
+ reasoning_tag: Optional[str] = None
149
+ remove_reasoning: Optional[str] = None # Deprecated alias for reasoning_tag
150
+ system_prompt_prefix: Optional[str] = None
151
+ accepts_settings: Optional[list] = None
152
+
153
+
154
+ # Load model settings from package resource
155
+ MODEL_SETTINGS = []
156
+ with importlib.resources.open_text("patch.resources", "model-settings.yml") as f:
157
+ model_settings_list = yaml.safe_load(f)
158
+ for model_settings_dict in model_settings_list:
159
+ MODEL_SETTINGS.append(ModelSettings(**model_settings_dict))
160
+
161
+
162
+ class ModelInfoManager:
163
+ MODEL_INFO_URL = (
164
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/"
165
+ "model_prices_and_context_window.json"
166
+ )
167
+ CACHE_TTL = 60 * 60 * 24 # 24 hours
168
+
169
+ def __init__(self):
170
+ self.cache_dir = Path.home() / ".patch" / "caches"
171
+ self.cache_file = self.cache_dir / "model_prices_and_context_window.json"
172
+ self.content = None
173
+ self.local_model_metadata = {}
174
+ self.verify_ssl = True
175
+ self._cache_loaded = False
176
+
177
+ # Manager for the cached OpenRouter model database
178
+ self.openrouter_manager = OpenRouterModelManager()
179
+
180
+ def set_verify_ssl(self, verify_ssl):
181
+ self.verify_ssl = verify_ssl
182
+ if hasattr(self, "openrouter_manager"):
183
+ self.openrouter_manager.set_verify_ssl(verify_ssl)
184
+
185
+ def _load_cache(self):
186
+ if self._cache_loaded:
187
+ return
188
+
189
+ try:
190
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
191
+ if self.cache_file.exists():
192
+ cache_age = time.time() - self.cache_file.stat().st_mtime
193
+ if cache_age < self.CACHE_TTL:
194
+ try:
195
+ self.content = json.loads(self.cache_file.read_text())
196
+ except json.JSONDecodeError:
197
+ # If the cache file is corrupted, treat it as missing
198
+ self.content = None
199
+ except OSError:
200
+ pass
201
+
202
+ self._cache_loaded = True
203
+
204
+ def _update_cache(self):
205
+ try:
206
+ import requests
207
+
208
+ # Respect the --no-verify-ssl switch
209
+ response = requests.get(self.MODEL_INFO_URL, timeout=5, verify=self.verify_ssl)
210
+ if response.status_code == 200:
211
+ self.content = response.json()
212
+ try:
213
+ self.cache_file.write_text(json.dumps(self.content, indent=4))
214
+ except OSError:
215
+ pass
216
+ except Exception as ex:
217
+ print(str(ex))
218
+ try:
219
+ # Save empty dict to cache file on failure
220
+ self.cache_file.write_text("{}")
221
+ except OSError:
222
+ pass
223
+
224
+ def get_model_from_cached_json_db(self, model):
225
+ data = self.local_model_metadata.get(model)
226
+ if data:
227
+ return data
228
+
229
+ # Ensure cache is loaded before checking content
230
+ self._load_cache()
231
+
232
+ if not self.content:
233
+ self._update_cache()
234
+
235
+ if not self.content:
236
+ return dict()
237
+
238
+ info = self.content.get(model, dict())
239
+ if info:
240
+ return info
241
+
242
+ pieces = model.split("/")
243
+ if len(pieces) == 2:
244
+ info = self.content.get(pieces[1])
245
+ if info and info.get("litellm_provider") == pieces[0]:
246
+ return info
247
+
248
+ return dict()
249
+
250
+ def get_model_info(self, model):
251
+ cached_info = self.get_model_from_cached_json_db(model)
252
+
253
+ litellm_info = None
254
+ if litellm._lazy_module or not cached_info:
255
+ try:
256
+ litellm_info = litellm.get_model_info(model)
257
+ except Exception as ex:
258
+ if "model_prices_and_context_window.json" not in str(ex):
259
+ print(str(ex))
260
+
261
+ if litellm_info:
262
+ return litellm_info
263
+
264
+ if not cached_info and model.startswith("openrouter/"):
265
+ # First try using the locally cached OpenRouter model database
266
+ openrouter_info = self.openrouter_manager.get_model_info(model)
267
+ if openrouter_info:
268
+ return openrouter_info
269
+
270
+ # Fallback to legacy web-scraping if the API cache does not contain the model
271
+ openrouter_info = self.fetch_openrouter_model_info(model)
272
+ if openrouter_info:
273
+ return openrouter_info
274
+
275
+ return cached_info
276
+
277
+ def fetch_openrouter_model_info(self, model):
278
+ """
279
+ Fetch model info by scraping the openrouter model page.
280
+ Expected URL: https://openrouter.ai/<model_route>
281
+ Example: openrouter/qwen/qwen-2.5-72b-instruct:free
282
+ Returns a dict with keys: max_tokens, max_input_tokens, max_output_tokens,
283
+ input_cost_per_token, output_cost_per_token.
284
+ """
285
+ url_part = model[len("openrouter/") :]
286
+ url = "https://openrouter.ai/" + url_part
287
+ try:
288
+ import requests
289
+
290
+ response = requests.get(url, timeout=5, verify=self.verify_ssl)
291
+ if response.status_code != 200:
292
+ return {}
293
+ html = response.text
294
+ import re
295
+
296
+ if re.search(
297
+ rf"The model\s*.*{re.escape(url_part)}.* is not available", html, re.IGNORECASE
298
+ ):
299
+ print(f"\033[91mError: Model '{url_part}' is not available\033[0m")
300
+ return {}
301
+ text = re.sub(r"<[^>]+>", " ", html)
302
+ context_match = re.search(r"([\d,]+)\s*context", text)
303
+ if context_match:
304
+ context_str = context_match.group(1).replace(",", "")
305
+ context_size = int(context_str)
306
+ else:
307
+ context_size = None
308
+ input_cost_match = re.search(r"\$\s*([\d.]+)\s*/M input tokens", text, re.IGNORECASE)
309
+ output_cost_match = re.search(r"\$\s*([\d.]+)\s*/M output tokens", text, re.IGNORECASE)
310
+ input_cost = float(input_cost_match.group(1)) / 1000000 if input_cost_match else None
311
+ output_cost = float(output_cost_match.group(1)) / 1000000 if output_cost_match else None
312
+ if context_size is None or input_cost is None or output_cost is None:
313
+ return {}
314
+ params = {
315
+ "max_input_tokens": context_size,
316
+ "max_tokens": context_size,
317
+ "max_output_tokens": context_size,
318
+ "input_cost_per_token": input_cost,
319
+ "output_cost_per_token": output_cost,
320
+ }
321
+ return params
322
+ except Exception as e:
323
+ print("Error fetching openrouter info:", str(e))
324
+ return {}
325
+
326
+
327
+ model_info_manager = ModelInfoManager()
328
+
329
+
330
+ class Model(ModelSettings):
331
+ def __init__(
332
+ self, model, weak_model=None, editor_model=None, editor_edit_format=None, verbose=False
333
+ ):
334
+ # Map any alias to its canonical name
335
+ model = MODEL_ALIASES.get(model, model)
336
+
337
+ self.name = model
338
+ self.verbose = verbose
339
+
340
+ self.max_chat_history_tokens = 1024
341
+ self.weak_model = None
342
+ self.editor_model = None
343
+ self.github_copilot_api_key = None
344
+
345
+ # Find the extra settings
346
+ self.extra_model_settings = next(
347
+ (ms for ms in MODEL_SETTINGS if ms.name == "patch/extra_params"), None
348
+ )
349
+
350
+ self.info = self.get_model_info(model)
351
+
352
+ # Are all needed keys/params available?
353
+ res = self.validate_environment()
354
+ self.missing_keys = res.get("missing_keys")
355
+ self.keys_in_environment = res.get("keys_in_environment")
356
+
357
+ max_input_tokens = self.info.get("max_input_tokens") or 0
358
+ # Calculate max_chat_history_tokens as 1/16th of max_input_tokens,
359
+ # with minimum 1k and maximum 8k
360
+ self.max_chat_history_tokens = min(max(max_input_tokens / 16, 1024), 8192)
361
+
362
+ self.configure_model_settings(model)
363
+ if weak_model is False:
364
+ self.weak_model_name = None
365
+ else:
366
+ self.get_weak_model(weak_model)
367
+
368
+ if editor_model is False:
369
+ self.editor_model_name = None
370
+ else:
371
+ self.get_editor_model(editor_model, editor_edit_format)
372
+
373
+ def get_model_info(self, model):
374
+ return model_info_manager.get_model_info(model)
375
+
376
+ def _copy_fields(self, source):
377
+ """Helper to copy fields from a ModelSettings instance to self"""
378
+ for field in fields(ModelSettings):
379
+ val = getattr(source, field.name)
380
+ setattr(self, field.name, copy.deepcopy(val))
381
+
382
+ # Handle backward compatibility: if remove_reasoning is set but reasoning_tag isn't,
383
+ # use remove_reasoning's value for reasoning_tag
384
+ if self.reasoning_tag is None and self.remove_reasoning is not None:
385
+ self.reasoning_tag = self.remove_reasoning
386
+
387
+ def configure_model_settings(self, model):
388
+ # Look for exact model match
389
+ exact_match = False
390
+ for ms in MODEL_SETTINGS:
391
+ # direct match, or match "provider/<model>"
392
+ if model == ms.name:
393
+ self._copy_fields(ms)
394
+ exact_match = True
395
+ break # Continue to apply overrides
396
+
397
+ # Initialize accepts_settings if it's None
398
+ if self.accepts_settings is None:
399
+ self.accepts_settings = []
400
+
401
+ model = model.lower()
402
+
403
+ # If no exact match, try generic settings
404
+ if not exact_match:
405
+ self.apply_generic_model_settings(model)
406
+
407
+ # Apply override settings last if they exist
408
+ if (
409
+ self.extra_model_settings
410
+ and self.extra_model_settings.extra_params
411
+ and self.extra_model_settings.name == "patch/extra_params"
412
+ ):
413
+ # Initialize extra_params if it doesn't exist
414
+ if not self.extra_params:
415
+ self.extra_params = {}
416
+
417
+ # Deep merge the extra_params dicts
418
+ for key, value in self.extra_model_settings.extra_params.items():
419
+ if isinstance(value, dict) and isinstance(self.extra_params.get(key), dict):
420
+ # For nested dicts, merge recursively
421
+ self.extra_params[key] = {**self.extra_params[key], **value}
422
+ else:
423
+ # For non-dict values, simply update
424
+ self.extra_params[key] = value
425
+
426
+ # Ensure OpenRouter models accept thinking_tokens and reasoning_effort
427
+ if self.name.startswith("openrouter/"):
428
+ if self.accepts_settings is None:
429
+ self.accepts_settings = []
430
+ if (
431
+ "thinking_tokens" not in self.accepts_settings
432
+ and "claude-opus-4.7" not in self.name
433
+ and "claude-opus-4-7" not in self.name
434
+ ):
435
+ self.accepts_settings.append("thinking_tokens")
436
+ if "reasoning_effort" not in self.accepts_settings:
437
+ self.accepts_settings.append("reasoning_effort")
438
+
439
+ def apply_generic_model_settings(self, model):
440
+ if "/o3-mini" in model:
441
+ self.edit_format = "diff"
442
+ self.use_repo_map = True
443
+ self.use_temperature = False
444
+ self.system_prompt_prefix = "Formatting re-enabled. "
445
+ self.system_prompt_prefix = "Formatting re-enabled. "
446
+ if "reasoning_effort" not in self.accepts_settings:
447
+ self.accepts_settings.append("reasoning_effort")
448
+ return # <--
449
+
450
+ if "gpt-4.1-mini" in model:
451
+ self.edit_format = "diff"
452
+ self.use_repo_map = True
453
+ self.reminder = "sys"
454
+ self.examples_as_sys_msg = False
455
+ return # <--
456
+
457
+ if "gpt-4.1" in model:
458
+ self.edit_format = "diff"
459
+ self.use_repo_map = True
460
+ self.reminder = "sys"
461
+ self.examples_as_sys_msg = False
462
+ return # <--
463
+
464
+ last_segment = model.split("/")[-1]
465
+ if last_segment in ("gpt-5", "gpt-5-2025-08-07"):
466
+ self.use_temperature = False
467
+ self.edit_format = "diff"
468
+ if "reasoning_effort" not in self.accepts_settings:
469
+ self.accepts_settings.append("reasoning_effort")
470
+ return # <--
471
+
472
+ if "/o1-mini" in model:
473
+ self.use_repo_map = True
474
+ self.use_temperature = False
475
+ self.use_system_prompt = False
476
+ return # <--
477
+
478
+ if "/o1-preview" in model:
479
+ self.edit_format = "diff"
480
+ self.use_repo_map = True
481
+ self.use_temperature = False
482
+ self.use_system_prompt = False
483
+ return # <--
484
+
485
+ if "/o1" in model:
486
+ self.edit_format = "diff"
487
+ self.use_repo_map = True
488
+ self.use_temperature = False
489
+ self.streaming = False
490
+ self.system_prompt_prefix = "Formatting re-enabled. "
491
+ if "reasoning_effort" not in self.accepts_settings:
492
+ self.accepts_settings.append("reasoning_effort")
493
+ return # <--
494
+
495
+ if "deepseek" in model and "v3" in model:
496
+ self.edit_format = "diff"
497
+ self.use_repo_map = True
498
+ self.reminder = "sys"
499
+ self.examples_as_sys_msg = True
500
+ return # <--
501
+
502
+ if "deepseek" in model and ("r1" in model or "reasoning" in model):
503
+ self.edit_format = "diff"
504
+ self.use_repo_map = True
505
+ self.examples_as_sys_msg = True
506
+ self.use_temperature = False
507
+ self.reasoning_tag = "think"
508
+ return # <--
509
+
510
+ if ("llama3" in model or "llama-3" in model) and "70b" in model:
511
+ self.edit_format = "diff"
512
+ self.use_repo_map = True
513
+ self.send_undo_reply = True
514
+ self.examples_as_sys_msg = True
515
+ return # <--
516
+
517
+ if "gpt-4-turbo" in model or ("gpt-4-" in model and "-preview" in model):
518
+ self.edit_format = "udiff"
519
+ self.use_repo_map = True
520
+ self.send_undo_reply = True
521
+ return # <--
522
+
523
+ if "gpt-4" in model or "claude-3-opus" in model:
524
+ self.edit_format = "diff"
525
+ self.use_repo_map = True
526
+ self.send_undo_reply = True
527
+ return # <--
528
+
529
+ if "gpt-3.5" in model or "gpt-4" in model:
530
+ self.reminder = "sys"
531
+ return # <--
532
+
533
+ if "sonnet-4-" in model or "opus-4-" in model or "haiku-4-" in model:
534
+ self.edit_format = "diff"
535
+ self.use_repo_map = True
536
+ self.examples_as_sys_msg = False
537
+ if "opus-4-" in model:
538
+ self.use_temperature = False
539
+ if (
540
+ "thinking_tokens" not in self.accepts_settings
541
+ and "4.7" not in model
542
+ and "4-7" not in model
543
+ ):
544
+ self.accepts_settings.append("thinking_tokens")
545
+ return # <--
546
+
547
+ if "3-7-sonnet" in model:
548
+ self.edit_format = "diff"
549
+ self.use_repo_map = True
550
+ self.examples_as_sys_msg = True
551
+ self.reminder = "user"
552
+ if "thinking_tokens" not in self.accepts_settings:
553
+ self.accepts_settings.append("thinking_tokens")
554
+ return # <--
555
+
556
+ if "3.5-sonnet" in model or "3-5-sonnet" in model:
557
+ self.edit_format = "diff"
558
+ self.use_repo_map = True
559
+ self.examples_as_sys_msg = True
560
+ self.reminder = "user"
561
+ return # <--
562
+
563
+ if model.startswith("o1-") or "/o1-" in model:
564
+ self.use_system_prompt = False
565
+ self.use_temperature = False
566
+ return # <--
567
+
568
+ if (
569
+ "qwen" in model
570
+ and "coder" in model
571
+ and ("2.5" in model or "2-5" in model)
572
+ and "32b" in model
573
+ ):
574
+ self.edit_format = "diff"
575
+ self.editor_edit_format = "editor-diff"
576
+ self.use_repo_map = True
577
+ return # <--
578
+
579
+ if "qwq" in model and "32b" in model and "preview" not in model:
580
+ self.edit_format = "diff"
581
+ self.editor_edit_format = "editor-diff"
582
+ self.use_repo_map = True
583
+ self.reasoning_tag = "think"
584
+ self.examples_as_sys_msg = True
585
+ self.use_temperature = 0.6
586
+ self.extra_params = dict(top_p=0.95)
587
+ return # <--
588
+
589
+ if "qwen3" in model and "235b" in model:
590
+ self.edit_format = "diff"
591
+ self.use_repo_map = True
592
+ self.system_prompt_prefix = "/no_think"
593
+ self.use_temperature = 0.7
594
+ self.extra_params = {"top_p": 0.8, "top_k": 20, "min_p": 0.0}
595
+ return # <--
596
+
597
+ # use the defaults
598
+ if self.edit_format == "diff":
599
+ self.use_repo_map = True
600
+ return # <--
601
+
602
+ def __str__(self):
603
+ return self.name
604
+
605
+ def get_weak_model(self, provided_weak_model_name):
606
+ # If weak_model_name is provided, override the model settings
607
+ if provided_weak_model_name:
608
+ self.weak_model_name = provided_weak_model_name
609
+
610
+ if not self.weak_model_name:
611
+ self.weak_model = self
612
+ return
613
+
614
+ if self.weak_model_name == self.name:
615
+ self.weak_model = self
616
+ return
617
+
618
+ self.weak_model = Model(
619
+ self.weak_model_name,
620
+ weak_model=False,
621
+ )
622
+ return self.weak_model
623
+
624
+ def commit_message_models(self):
625
+ return [self.weak_model, self]
626
+
627
+ def get_editor_model(self, provided_editor_model_name, editor_edit_format):
628
+ # If editor_model_name is provided, override the model settings
629
+ if provided_editor_model_name:
630
+ self.editor_model_name = provided_editor_model_name
631
+ if editor_edit_format:
632
+ self.editor_edit_format = editor_edit_format
633
+
634
+ if not self.editor_model_name or self.editor_model_name == self.name:
635
+ self.editor_model = self
636
+ else:
637
+ self.editor_model = Model(
638
+ self.editor_model_name,
639
+ editor_model=False,
640
+ )
641
+
642
+ if not self.editor_edit_format:
643
+ self.editor_edit_format = self.editor_model.edit_format
644
+ if self.editor_edit_format in ("diff", "whole", "diff-fenced"):
645
+ self.editor_edit_format = "editor-" + self.editor_edit_format
646
+
647
+ return self.editor_model
648
+
649
+ def tokenizer(self, text):
650
+ return litellm.encode(model=self.name, text=text)
651
+
652
+ def token_count(self, messages) -> int:
653
+ if type(messages) is list:
654
+ try:
655
+ return litellm.token_counter(model=self.name, messages=messages)
656
+ except Exception as err:
657
+ print(f"Unable to count tokens: {err}")
658
+ return 0
659
+
660
+ if type(messages) is str:
661
+ msgs = messages
662
+ else:
663
+ msgs = json.dumps(messages)
664
+
665
+ try:
666
+ return len(self.tokenizer(msgs))
667
+ except Exception as err:
668
+ print(f"Unable to count tokens: {err}")
669
+ return 0
670
+
671
+ def token_count_for_image(self, fname) -> int:
672
+ """
673
+ Calculate the token cost for an image assuming high detail.
674
+ The token cost is determined by the size of the image.
675
+ :param fname: The filename of the image.
676
+ :return: The token cost for the image.
677
+ """
678
+ width, height = self.get_image_size(fname)
679
+
680
+ # If the image is larger than 2048 in any dimension, scale it down to fit within 2048x2048
681
+ max_dimension = max(width, height)
682
+ if max_dimension > 2048:
683
+ scale_factor = 2048 / max_dimension
684
+ width = int(width * scale_factor)
685
+ height = int(height * scale_factor)
686
+
687
+ # Scale the image such that the shortest side is 768 pixels long
688
+ min_dimension = min(width, height)
689
+ scale_factor = 768 / min_dimension
690
+ width = int(width * scale_factor)
691
+ height = int(height * scale_factor)
692
+
693
+ # Calculate the number of 512x512 tiles needed to cover the image
694
+ tiles_width = math.ceil(width / 512)
695
+ tiles_height = math.ceil(height / 512)
696
+ num_tiles = tiles_width * tiles_height
697
+
698
+ # Each tile costs 170 tokens, and there's an additional fixed cost of 85 tokens
699
+ token_cost = num_tiles * 170 + 85
700
+ return token_cost
701
+
702
+ def get_image_size(self, fname) -> tuple[int, int]:
703
+ """
704
+ Retrieve the size of an image.
705
+ :param fname: The filename of the image.
706
+ :return: A tuple (width, height) representing the image size in pixels.
707
+ """
708
+ with Image.open(fname) as img:
709
+ return img.size
710
+
711
+ def fast_validate_environment(self):
712
+ """Fast path for common models. Avoids forcing litellm import."""
713
+
714
+ model = self.name
715
+
716
+ pieces = model.split("/")
717
+ if len(pieces) > 1:
718
+ provider = pieces[0]
719
+ else:
720
+ provider = None
721
+
722
+ keymap = dict(
723
+ openrouter="OPENROUTER_API_KEY",
724
+ openai="OPENAI_API_KEY",
725
+ deepseek="DEEPSEEK_API_KEY",
726
+ gemini="GEMINI_API_KEY",
727
+ anthropic="ANTHROPIC_API_KEY",
728
+ groq="GROQ_API_KEY",
729
+ fireworks_ai="FIREWORKS_API_KEY",
730
+ )
731
+ var = None
732
+ if model in OPENAI_MODELS:
733
+ var = "OPENAI_API_KEY"
734
+ elif model in ANTHROPIC_MODELS:
735
+ var = "ANTHROPIC_API_KEY"
736
+ else:
737
+ var = keymap.get(provider)
738
+
739
+ if var and os.environ.get(var):
740
+ return dict(keys_in_environment=[var], missing_keys=[])
741
+
742
+ def validate_environment(self):
743
+ res = self.fast_validate_environment()
744
+ if res:
745
+ return res
746
+
747
+ # https://github.com/BerriAI/litellm/issues/3190
748
+
749
+ model = self.name
750
+ res = litellm.validate_environment(model)
751
+
752
+ # If missing AWS credential keys but AWS_PROFILE is set, consider AWS credentials valid
753
+ if res["missing_keys"] and any(
754
+ key in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] for key in res["missing_keys"]
755
+ ):
756
+ if model.startswith("bedrock/") or model.startswith("us.anthropic."):
757
+ if os.environ.get("AWS_PROFILE"):
758
+ res["missing_keys"] = [
759
+ k
760
+ for k in res["missing_keys"]
761
+ if k not in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
762
+ ]
763
+ if not res["missing_keys"]:
764
+ res["keys_in_environment"] = True
765
+
766
+ if res["keys_in_environment"]:
767
+ return res
768
+ if res["missing_keys"]:
769
+ return res
770
+
771
+ provider = self.info.get("litellm_provider", "").lower()
772
+ if provider == "cohere_chat":
773
+ return validate_variables(["COHERE_API_KEY"])
774
+ if provider == "gemini":
775
+ return validate_variables(["GEMINI_API_KEY"])
776
+ if provider == "groq":
777
+ return validate_variables(["GROQ_API_KEY"])
778
+
779
+ return res
780
+
781
+ def get_repo_map_tokens(self):
782
+ map_tokens = 1024
783
+ max_inp_tokens = self.info.get("max_input_tokens")
784
+ if max_inp_tokens:
785
+ map_tokens = max_inp_tokens / 8
786
+ map_tokens = min(map_tokens, 4096)
787
+ map_tokens = max(map_tokens, 1024)
788
+ return map_tokens
789
+
790
+ def set_reasoning_effort(self, effort):
791
+ """Set the reasoning effort parameter for models that support it"""
792
+ if effort is not None:
793
+ if self.name.startswith("openrouter/"):
794
+ if not self.extra_params:
795
+ self.extra_params = {}
796
+ if "extra_body" not in self.extra_params:
797
+ self.extra_params["extra_body"] = {}
798
+ self.extra_params["extra_body"]["reasoning"] = {"effort": effort}
799
+ else:
800
+ if not self.extra_params:
801
+ self.extra_params = {}
802
+ if "extra_body" not in self.extra_params:
803
+ self.extra_params["extra_body"] = {}
804
+ self.extra_params["extra_body"]["reasoning_effort"] = effort
805
+
806
+ def parse_token_value(self, value):
807
+ """
808
+ Parse a token value string into an integer.
809
+ Accepts formats: 8096, "8k", "10.5k", "0.5M", "10K", etc.
810
+
811
+ Args:
812
+ value: String or int token value
813
+
814
+ Returns:
815
+ Integer token value
816
+ """
817
+ if isinstance(value, int):
818
+ return value
819
+
820
+ if not isinstance(value, str):
821
+ return int(value) # Try to convert to int
822
+
823
+ value = value.strip().upper()
824
+
825
+ if value.endswith("K"):
826
+ multiplier = 1024
827
+ value = value[:-1]
828
+ elif value.endswith("M"):
829
+ multiplier = 1024 * 1024
830
+ value = value[:-1]
831
+ else:
832
+ multiplier = 1
833
+
834
+ # Convert to float first to handle decimal values like "10.5k"
835
+ return int(float(value) * multiplier)
836
+
837
+ def set_thinking_tokens(self, value):
838
+ """
839
+ Set the thinking token budget for models that support it.
840
+ Accepts formats: 8096, "8k", "10.5k", "0.5M", "10K", etc.
841
+ Pass "0" to disable thinking tokens.
842
+ """
843
+ if value is not None:
844
+ num_tokens = self.parse_token_value(value)
845
+ self.use_temperature = False
846
+ if not self.extra_params:
847
+ self.extra_params = {}
848
+
849
+ # OpenRouter models use 'reasoning' instead of 'thinking'
850
+ if self.name.startswith("openrouter/"):
851
+ if "extra_body" not in self.extra_params:
852
+ self.extra_params["extra_body"] = {}
853
+ if num_tokens > 0:
854
+ self.extra_params["extra_body"]["reasoning"] = {"max_tokens": num_tokens}
855
+ else:
856
+ if "reasoning" in self.extra_params["extra_body"]:
857
+ del self.extra_params["extra_body"]["reasoning"]
858
+ else:
859
+ if num_tokens > 0:
860
+ self.extra_params["thinking"] = {"type": "enabled", "budget_tokens": num_tokens}
861
+ else:
862
+ if "thinking" in self.extra_params:
863
+ del self.extra_params["thinking"]
864
+
865
+ def get_raw_thinking_tokens(self):
866
+ """Get formatted thinking token budget if available"""
867
+ budget = None
868
+
869
+ if self.extra_params:
870
+ # Check for OpenRouter reasoning format
871
+ if self.name.startswith("openrouter/"):
872
+ if (
873
+ "extra_body" in self.extra_params
874
+ and "reasoning" in self.extra_params["extra_body"]
875
+ and "max_tokens" in self.extra_params["extra_body"]["reasoning"]
876
+ ):
877
+ budget = self.extra_params["extra_body"]["reasoning"]["max_tokens"]
878
+ # Check for standard thinking format
879
+ elif (
880
+ "thinking" in self.extra_params and "budget_tokens" in self.extra_params["thinking"]
881
+ ):
882
+ budget = self.extra_params["thinking"]["budget_tokens"]
883
+
884
+ return budget
885
+
886
+ def get_thinking_tokens(self):
887
+ budget = self.get_raw_thinking_tokens()
888
+
889
+ if budget is not None:
890
+ # Format as xx.yK for thousands, xx.yM for millions
891
+ if budget >= 1024 * 1024:
892
+ value = budget / (1024 * 1024)
893
+ if value == int(value):
894
+ return f"{int(value)}M"
895
+ else:
896
+ return f"{value:.1f}M"
897
+ else:
898
+ value = budget / 1024
899
+ if value == int(value):
900
+ return f"{int(value)}k"
901
+ else:
902
+ return f"{value:.1f}k"
903
+ return None
904
+
905
+ def get_reasoning_effort(self):
906
+ """Get reasoning effort value if available"""
907
+ if self.extra_params:
908
+ # Check for OpenRouter reasoning format
909
+ if self.name.startswith("openrouter/"):
910
+ if (
911
+ "extra_body" in self.extra_params
912
+ and "reasoning" in self.extra_params["extra_body"]
913
+ and "effort" in self.extra_params["extra_body"]["reasoning"]
914
+ ):
915
+ return self.extra_params["extra_body"]["reasoning"]["effort"]
916
+ # Check for standard reasoning_effort format (e.g. in extra_body)
917
+ elif (
918
+ "extra_body" in self.extra_params
919
+ and "reasoning_effort" in self.extra_params["extra_body"]
920
+ ):
921
+ return self.extra_params["extra_body"]["reasoning_effort"]
922
+ return None
923
+
924
+ def is_deepseek_r1(self):
925
+ name = self.name.lower()
926
+ if "deepseek" not in name:
927
+ return
928
+ return "r1" in name or "reasoner" in name
929
+
930
+ def is_ollama(self):
931
+ return self.name.startswith("ollama/") or self.name.startswith("ollama_chat/")
932
+
933
+ def get_github_copilot_api_key(self, extra_headers):
934
+ token = self.github_copilot_api_key
935
+ if token:
936
+ try:
937
+ token_data = dict(part.split("=", 1) for part in token.split(";") if "=" in part)
938
+ expires_at = token_data.get("exp")
939
+ if expires_at is None or int(expires_at) >= int(datetime.now().timestamp()):
940
+ return token
941
+ except (TypeError, ValueError):
942
+ return token
943
+
944
+ if not token or expires_at is not None:
945
+ import requests
946
+
947
+ class GitHubCopilotTokenError(Exception):
948
+ """Custom exception for GitHub Copilot token-related errors."""
949
+
950
+ pass
951
+
952
+ # Validate GitHub Copilot token exists
953
+ if "GITHUB_COPILOT_TOKEN" not in os.environ:
954
+ raise KeyError("GITHUB_COPILOT_TOKEN environment variable not found")
955
+
956
+ github_token = os.environ["GITHUB_COPILOT_TOKEN"]
957
+ if not github_token.strip():
958
+ raise KeyError("GITHUB_COPILOT_TOKEN environment variable is empty")
959
+
960
+ headers = {
961
+ "Authorization": f"Bearer {os.environ['GITHUB_COPILOT_TOKEN']}",
962
+ "Editor-Version": extra_headers["Editor-Version"],
963
+ "Copilot-Integration-Id": extra_headers["Copilot-Integration-Id"],
964
+ "Content-Type": "application/json",
965
+ }
966
+
967
+ url = "https://api.github.com/copilot_internal/v2/token"
968
+ res = requests.get(url, headers=headers, timeout=(5, 15))
969
+ if res.status_code != 200:
970
+ safe_headers = {k: v for k, v in headers.items() if k != "Authorization"}
971
+ token_preview = github_token[:5] + "..." if len(github_token) >= 5 else github_token
972
+ safe_headers["Authorization"] = f"Bearer {token_preview}"
973
+ raise GitHubCopilotTokenError(
974
+ f"GitHub Copilot API request failed (Status: {res.status_code})\n"
975
+ f"URL: {url}\n"
976
+ f"Headers: {json.dumps(safe_headers, indent=2)}\n"
977
+ f"JSON: {res.text}"
978
+ )
979
+
980
+ response_data = res.json()
981
+ token = response_data.get("token")
982
+ if not token:
983
+ raise GitHubCopilotTokenError("Response missing 'token' field")
984
+
985
+ self.github_copilot_api_key = token
986
+
987
+ return self.github_copilot_api_key
988
+
989
+ def send_completion(self, messages, functions, stream, temperature=None):
990
+ if os.environ.get("PATCH_SANITY_CHECK_TURNS"):
991
+ sanity_check_messages(messages)
992
+
993
+ if self.is_deepseek_r1():
994
+ messages = ensure_alternating_roles(messages)
995
+
996
+ kwargs = dict(
997
+ model=self.name,
998
+ stream=stream,
999
+ )
1000
+
1001
+ if self.use_temperature is not False:
1002
+ if temperature is None:
1003
+ if isinstance(self.use_temperature, bool):
1004
+ temperature = 0
1005
+ else:
1006
+ temperature = float(self.use_temperature)
1007
+
1008
+ kwargs["temperature"] = temperature
1009
+
1010
+ if functions is not None:
1011
+ function = functions[0]
1012
+ kwargs["tools"] = [dict(type="function", function=function)]
1013
+ kwargs["tool_choice"] = {"type": "function", "function": {"name": function["name"]}}
1014
+ if self.extra_params:
1015
+ kwargs.update(self.extra_params)
1016
+ if self.is_ollama() and "num_ctx" not in kwargs:
1017
+ num_ctx = int(self.token_count(messages) * 1.25) + 8192
1018
+ kwargs["num_ctx"] = num_ctx
1019
+ key = json.dumps(kwargs, sort_keys=True).encode()
1020
+
1021
+ # dump(kwargs)
1022
+
1023
+ hash_object = hashlib.sha1(key)
1024
+ if "timeout" not in kwargs:
1025
+ kwargs["timeout"] = request_timeout
1026
+ if self.verbose:
1027
+ dump(kwargs)
1028
+ kwargs["messages"] = messages
1029
+
1030
+ # Are we using github copilot?
1031
+ if self.name.startswith("github/") and "GITHUB_COPILOT_TOKEN" in os.environ:
1032
+ if "extra_headers" not in kwargs:
1033
+ kwargs["extra_headers"] = {
1034
+ "Editor-Version": f"patch/{__version__}",
1035
+ "Copilot-Integration-Id": "vscode-chat",
1036
+ }
1037
+
1038
+ kwargs["api_key"] = self.get_github_copilot_api_key(kwargs["extra_headers"])
1039
+
1040
+ res = litellm.completion(**kwargs)
1041
+ return hash_object, res
1042
+
1043
+ def simple_send_with_retries(self, messages):
1044
+ from patch.exceptions import LiteLLMExceptions
1045
+
1046
+ litellm_ex = LiteLLMExceptions()
1047
+ if "deepseek-reasoner" in self.name:
1048
+ messages = ensure_alternating_roles(messages)
1049
+ retry_delay = 0.125
1050
+
1051
+ if self.verbose:
1052
+ dump(messages)
1053
+
1054
+ while True:
1055
+ try:
1056
+ kwargs = {
1057
+ "messages": messages,
1058
+ "functions": None,
1059
+ "stream": False,
1060
+ }
1061
+
1062
+ _hash, response = self.send_completion(**kwargs)
1063
+ if not response or not hasattr(response, "choices") or not response.choices:
1064
+ return None
1065
+ res = response.choices[0].message.content
1066
+ from patch.reasoning_tags import remove_reasoning_content
1067
+
1068
+ return remove_reasoning_content(res, self.reasoning_tag)
1069
+
1070
+ except litellm_ex.exceptions_tuple() as err:
1071
+ ex_info = litellm_ex.get_ex_info(err)
1072
+ print(str(err))
1073
+ if ex_info.description:
1074
+ print(ex_info.description)
1075
+ should_retry = ex_info.retry
1076
+ if should_retry:
1077
+ retry_delay *= 2
1078
+ if retry_delay > RETRY_TIMEOUT:
1079
+ should_retry = False
1080
+ if not should_retry:
1081
+ return None
1082
+ print(f"Retrying in {retry_delay:.1f} seconds...")
1083
+ time.sleep(retry_delay)
1084
+ continue
1085
+ except AttributeError:
1086
+ return None
1087
+
1088
+
1089
+ def register_models(model_settings_fnames):
1090
+ files_loaded = []
1091
+ for model_settings_fname in model_settings_fnames:
1092
+ if not os.path.exists(model_settings_fname):
1093
+ continue
1094
+
1095
+ if not Path(model_settings_fname).read_text().strip():
1096
+ continue
1097
+
1098
+ try:
1099
+ with open(model_settings_fname, "r") as model_settings_file:
1100
+ model_settings_list = yaml.safe_load(model_settings_file)
1101
+
1102
+ for model_settings_dict in model_settings_list:
1103
+ model_settings = ModelSettings(**model_settings_dict)
1104
+
1105
+ # Remove all existing settings for this model name
1106
+ MODEL_SETTINGS[:] = [ms for ms in MODEL_SETTINGS if ms.name != model_settings.name]
1107
+ # Add the new settings
1108
+ MODEL_SETTINGS.append(model_settings)
1109
+ except Exception as e:
1110
+ raise Exception(f"Error loading model settings from {model_settings_fname}: {e}")
1111
+ files_loaded.append(model_settings_fname)
1112
+
1113
+ return files_loaded
1114
+
1115
+
1116
+ def register_litellm_models(model_fnames):
1117
+ files_loaded = []
1118
+ for model_fname in model_fnames:
1119
+ if not os.path.exists(model_fname):
1120
+ continue
1121
+
1122
+ try:
1123
+ data = Path(model_fname).read_text()
1124
+ if not data.strip():
1125
+ continue
1126
+ model_def = json5.loads(data)
1127
+ if not model_def:
1128
+ continue
1129
+
1130
+ # Defer registration with litellm to faster path.
1131
+ model_info_manager.local_model_metadata.update(model_def)
1132
+ except Exception as e:
1133
+ raise Exception(f"Error loading model definition from {model_fname}: {e}")
1134
+
1135
+ files_loaded.append(model_fname)
1136
+
1137
+ return files_loaded
1138
+
1139
+
1140
+ def validate_variables(vars):
1141
+ missing = []
1142
+ for var in vars:
1143
+ if var not in os.environ:
1144
+ missing.append(var)
1145
+ if missing:
1146
+ return dict(keys_in_environment=False, missing_keys=missing)
1147
+ return dict(keys_in_environment=True, missing_keys=missing)
1148
+
1149
+
1150
+ def sanity_check_models(io, main_model):
1151
+ problem_main = sanity_check_model(io, main_model)
1152
+
1153
+ problem_weak = None
1154
+ if main_model.weak_model and main_model.weak_model is not main_model:
1155
+ problem_weak = sanity_check_model(io, main_model.weak_model)
1156
+
1157
+ problem_editor = None
1158
+ if (
1159
+ main_model.editor_model
1160
+ and main_model.editor_model is not main_model
1161
+ and main_model.editor_model is not main_model.weak_model
1162
+ ):
1163
+ problem_editor = sanity_check_model(io, main_model.editor_model)
1164
+
1165
+ return problem_main or problem_weak or problem_editor
1166
+
1167
+
1168
+ def sanity_check_model(io, model):
1169
+ show = False
1170
+
1171
+ if model.missing_keys:
1172
+ show = True
1173
+ io.tool_warning(f"Warning: {model} expects these environment variables")
1174
+ for key in model.missing_keys:
1175
+ value = os.environ.get(key, "")
1176
+ status = "Set" if value else "Not set"
1177
+ io.tool_output(f"- {key}: {status}")
1178
+
1179
+ if platform.system() == "Windows":
1180
+ io.tool_output(
1181
+ "Note: You may need to restart your terminal or command prompt for `setx` to take"
1182
+ " effect."
1183
+ )
1184
+
1185
+ elif not model.keys_in_environment:
1186
+ show = True
1187
+ io.tool_warning(f"Warning for {model}: Unknown which environment variables are required.")
1188
+
1189
+ # Check for model-specific dependencies
1190
+ check_for_dependencies(io, model.name)
1191
+
1192
+ if not model.info:
1193
+ show = True
1194
+ io.tool_warning(
1195
+ f"Warning for {model}: Unknown context window size and costs, using sane defaults."
1196
+ )
1197
+
1198
+ possible_matches = fuzzy_match_models(model.name)
1199
+ if possible_matches:
1200
+ io.tool_output("Did you mean one of these?")
1201
+ for match in possible_matches:
1202
+ io.tool_output(f"- {match}")
1203
+
1204
+ return show
1205
+
1206
+
1207
+ def check_for_dependencies(io, model_name):
1208
+ """
1209
+ Check for model-specific dependencies and install them if needed.
1210
+
1211
+ Args:
1212
+ io: The IO object for user interaction
1213
+ model_name: The name of the model to check dependencies for
1214
+ """
1215
+ # Check if this is a Bedrock model and ensure boto3 is installed
1216
+ if model_name.startswith("bedrock/"):
1217
+ check_pip_install_extra(
1218
+ io, "boto3", "AWS Bedrock models require the boto3 package.", ["boto3"]
1219
+ )
1220
+
1221
+ # Check if this is a Vertex AI model and ensure google-cloud-aiplatform is installed
1222
+ elif model_name.startswith("vertex_ai/"):
1223
+ check_pip_install_extra(
1224
+ io,
1225
+ "google.cloud.aiplatform",
1226
+ "Google Vertex AI models require the google-cloud-aiplatform package.",
1227
+ ["google-cloud-aiplatform"],
1228
+ )
1229
+
1230
+
1231
+ def fuzzy_match_models(name):
1232
+ name = name.lower()
1233
+
1234
+ chat_models = set()
1235
+ model_metadata = list(litellm.model_cost.items())
1236
+ model_metadata += list(model_info_manager.local_model_metadata.items())
1237
+
1238
+ for orig_model, attrs in model_metadata:
1239
+ model = orig_model.lower()
1240
+ if attrs.get("mode") != "chat":
1241
+ continue
1242
+ provider = attrs.get("litellm_provider", "").lower()
1243
+ if not provider:
1244
+ continue
1245
+ provider += "/"
1246
+
1247
+ if model.startswith(provider):
1248
+ fq_model = orig_model
1249
+ else:
1250
+ fq_model = provider + orig_model
1251
+
1252
+ chat_models.add(fq_model)
1253
+ chat_models.add(orig_model)
1254
+
1255
+ chat_models = sorted(chat_models)
1256
+ # exactly matching model
1257
+ # matching_models = [
1258
+ # (fq,m) for fq,m in chat_models
1259
+ # if name == fq or name == m
1260
+ # ]
1261
+ # if matching_models:
1262
+ # return matching_models
1263
+
1264
+ # Check for model names containing the name
1265
+ matching_models = [m for m in chat_models if name in m]
1266
+ if matching_models:
1267
+ return sorted(set(matching_models))
1268
+
1269
+ # Check for slight misspellings
1270
+ models = set(chat_models)
1271
+ matching_models = difflib.get_close_matches(name, models, n=3, cutoff=0.8)
1272
+
1273
+ return sorted(set(matching_models))
1274
+
1275
+
1276
+ def print_matching_models(io, search):
1277
+ matches = fuzzy_match_models(search)
1278
+ if matches:
1279
+ io.tool_output(f'Models which match "{search}":')
1280
+ for model in matches:
1281
+ io.tool_output(f"- {model}")
1282
+ else:
1283
+ io.tool_output(f'No models match "{search}".')
1284
+
1285
+
1286
+ def get_model_settings_as_yaml():
1287
+ from dataclasses import fields
1288
+
1289
+ import yaml
1290
+
1291
+ model_settings_list = []
1292
+ # Add default settings first with all field values
1293
+ defaults = {}
1294
+ for field in fields(ModelSettings):
1295
+ defaults[field.name] = field.default
1296
+ defaults["name"] = "(default values)"
1297
+ model_settings_list.append(defaults)
1298
+
1299
+ # Sort model settings by name
1300
+ for ms in sorted(MODEL_SETTINGS, key=lambda x: x.name):
1301
+ # Create dict with explicit field order
1302
+ model_settings_dict = {}
1303
+ for field in fields(ModelSettings):
1304
+ value = getattr(ms, field.name)
1305
+ if value != field.default:
1306
+ model_settings_dict[field.name] = value
1307
+ model_settings_list.append(model_settings_dict)
1308
+ # Add blank line between entries
1309
+ model_settings_list.append(None)
1310
+
1311
+ # Filter out None values before dumping
1312
+ yaml_str = yaml.dump(
1313
+ [ms for ms in model_settings_list if ms is not None],
1314
+ default_flow_style=False,
1315
+ sort_keys=False, # Preserve field order from dataclass
1316
+ )
1317
+ # Add actual blank lines between entries
1318
+ return yaml_str.replace("\n- ", "\n\n- ")
1319
+
1320
+
1321
+ def main():
1322
+ if len(sys.argv) < 2:
1323
+ print("Usage: python models.py <model_name> or python models.py --yaml")
1324
+ sys.exit(1)
1325
+
1326
+ if sys.argv[1] == "--yaml":
1327
+ yaml_string = get_model_settings_as_yaml()
1328
+ print(yaml_string)
1329
+ else:
1330
+ model_name = sys.argv[1]
1331
+ matching_models = fuzzy_match_models(model_name)
1332
+
1333
+ if matching_models:
1334
+ print(f"Matching models for '{model_name}':")
1335
+ for model in matching_models:
1336
+ print(model)
1337
+ else:
1338
+ print(f"No matching models found for '{model_name}'.")
1339
+
1340
+
1341
+ if __name__ == "__main__":
1342
+ main()