devcommit 0.1.5.6__tar.gz → 0.1.5.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcommit
3
- Version: 0.1.5.6
3
+ Version: 0.1.5.7
4
4
  Summary: AI-powered git commit message generator
5
5
  License: GNU GENERAL PUBLIC LICENSE
6
6
  Version 3, 29 June 2007
@@ -1184,6 +1184,8 @@ devcommit --stageAll --changelog
1184
1184
  - `--files` or `-f`: Stage and commit specific files or folders (can specify multiple)
1185
1185
  - `--push` or `-p`: Push commits to remote after committing
1186
1186
  - `--changelog` or `-c`: Generate changelog file from changes
1187
+ - `--models`: Fetch and list models from the configured `AI_PROVIDER`
1188
+ - `--free` / `--paid`: With `--models`, limit OpenRouter results to free or paid models
1187
1189
 
1188
1190
  ### Examples
1189
1191
 
@@ -1262,6 +1264,19 @@ export OPENROUTER_MODEL='meta-llama/llama-3.3-70b-instruct:free'
1262
1264
  devcommit
1263
1265
  ```
1264
1266
 
1267
+ To fetch the live model catalogue for your configured provider without opening a browser:
1268
+
1269
+ ```bash
1270
+ AI_PROVIDER=openrouter devcommit --models
1271
+
1272
+ # Only free OpenRouter models
1273
+ AI_PROVIDER=openrouter devcommit --models --free
1274
+ ```
1275
+
1276
+ For OpenRouter, the table identifies free and paid models and shows their live token prices. For other providers, it lists the models accessible with that provider's configured API key; Ollama lists models installed locally. Copy an ID into the matching provider model setting, such as `OPENROUTER_MODEL`.
1277
+
1278
+ Set `REASONING_EFFORT=none` to disable reasoning where the selected model supports it and favor faster commit messages. The default is `auto`; `low`, `medium`, `high`, `xhigh`, and `max` are also accepted where supported.
1279
+
1265
1280
  **Popular free models on OpenRouter (add `:free` suffix):**
1266
1281
 
1267
1282
  **Recommended Models:**
@@ -1363,7 +1378,8 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
1363
1378
  | `MAX_NO` | Number of commit message suggestions | `1` | Any positive integer |
1364
1379
  | `COMMIT_TYPE` | Style of commit messages | `general` | `general`, `conventional`, etc. |
1365
1380
  | `COMMIT_MODE` | Default commit strategy | `auto` | `auto`, `directory`, `global`, `related` |
1366
- | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
1381
+ | `REASONING_EFFORT` | Reasoning effort for supported models | `auto` | `auto`, `none`, `low`, `medium`, `high`, `xhigh`, `max` |
1382
+ | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated patterns or absolute paths within one repo |
1367
1383
  | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
1368
1384
  | `CHANGELOG_DIR` | Directory for changelog files | `changelogs` | Any directory path |
1369
1385
  | `CHANGELOG_MODE` | Changelog file strategy | `timestamped` | `timestamped`, `branch` |
@@ -479,6 +479,8 @@ devcommit --stageAll --changelog
479
479
  - `--files` or `-f`: Stage and commit specific files or folders (can specify multiple)
480
480
  - `--push` or `-p`: Push commits to remote after committing
481
481
  - `--changelog` or `-c`: Generate changelog file from changes
482
+ - `--models`: Fetch and list models from the configured `AI_PROVIDER`
483
+ - `--free` / `--paid`: With `--models`, limit OpenRouter results to free or paid models
482
484
 
483
485
  ### Examples
484
486
 
@@ -557,6 +559,19 @@ export OPENROUTER_MODEL='meta-llama/llama-3.3-70b-instruct:free'
557
559
  devcommit
558
560
  ```
559
561
 
562
+ To fetch the live model catalogue for your configured provider without opening a browser:
563
+
564
+ ```bash
565
+ AI_PROVIDER=openrouter devcommit --models
566
+
567
+ # Only free OpenRouter models
568
+ AI_PROVIDER=openrouter devcommit --models --free
569
+ ```
570
+
571
+ For OpenRouter, the table identifies free and paid models and shows their live token prices. For other providers, it lists the models accessible with that provider's configured API key; Ollama lists models installed locally. Copy an ID into the matching provider model setting, such as `OPENROUTER_MODEL`.
572
+
573
+ Set `REASONING_EFFORT=none` to disable reasoning where the selected model supports it and favor faster commit messages. The default is `auto`; `low`, `medium`, `high`, `xhigh`, and `max` are also accepted where supported.
574
+
560
575
  **Popular free models on OpenRouter (add `:free` suffix):**
561
576
 
562
577
  **Recommended Models:**
@@ -658,7 +673,8 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
658
673
  | `MAX_NO` | Number of commit message suggestions | `1` | Any positive integer |
659
674
  | `COMMIT_TYPE` | Style of commit messages | `general` | `general`, `conventional`, etc. |
660
675
  | `COMMIT_MODE` | Default commit strategy | `auto` | `auto`, `directory`, `global`, `related` |
661
- | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
676
+ | `REASONING_EFFORT` | Reasoning effort for supported models | `auto` | `auto`, `none`, `low`, `medium`, `high`, `xhigh`, `max` |
677
+ | `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated patterns or absolute paths within one repo |
662
678
  | `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
663
679
  | `CHANGELOG_DIR` | Directory for changelog files | `changelogs` | Any directory path |
664
680
  | `CHANGELOG_MODE` | Changelog file strategy | `timestamped` | `timestamped`, `branch` |
@@ -34,6 +34,19 @@ except ImportError:
34
34
  sys.stderr = _stderr
35
35
  _devnull.close()
36
36
 
37
+ REASONING_EFFORTS = {"auto", "none", "low", "medium", "high", "xhigh", "max"}
38
+
39
+
40
+ def normalize_reasoning_effort(value: str) -> str:
41
+ effort = value.lower()
42
+ if effort not in REASONING_EFFORTS:
43
+ raise ValueError("REASONING_EFFORT must be auto, none, low, medium, high, xhigh, or max")
44
+ return effort
45
+
46
+
47
+ def reasoning_extra_body(effort: str) -> dict:
48
+ return {} if effort == "auto" else {"reasoning_effort": effort}
49
+
37
50
 
38
51
  class AIProvider(ABC):
39
52
  """Base class for AI providers"""
@@ -106,11 +119,12 @@ class GeminiProvider(AIProvider):
106
119
  class OpenAIProvider(AIProvider):
107
120
  """OpenAI GPT provider"""
108
121
 
109
- def __init__(self, api_key: str, model: str = "gpt-4o-mini"):
122
+ def __init__(self, api_key: str, model: str = "gpt-4o-mini", reasoning_effort: str = "auto"):
110
123
  if not openai:
111
124
  raise ImportError("openai not installed. Run: pip install openai")
112
125
  self.client = openai.OpenAI(api_key=api_key)
113
126
  self.model = model
127
+ self.reasoning_effort = normalize_reasoning_effort(reasoning_effort)
114
128
 
115
129
  def generate_commit_message(
116
130
  self, diff: str, prompt: str, max_tokens: int
@@ -123,6 +137,7 @@ class OpenAIProvider(AIProvider):
123
137
  ],
124
138
  max_tokens=max_tokens,
125
139
  temperature=0.7,
140
+ extra_body=reasoning_extra_body(self.reasoning_effort),
126
141
  )
127
142
  if not response or not response.choices:
128
143
  raise ValueError("OpenAI returned empty response (no choices).")
@@ -132,13 +147,14 @@ class OpenAIProvider(AIProvider):
132
147
  class GroqProvider(AIProvider):
133
148
  """Groq AI provider (OpenAI-compatible)"""
134
149
 
135
- def __init__(self, api_key: str, model: str = "llama-3.3-70b-versatile"):
150
+ def __init__(self, api_key: str, model: str = "llama-3.3-70b-versatile", reasoning_effort: str = "auto"):
136
151
  if not openai:
137
152
  raise ImportError("openai not installed. Run: pip install openai")
138
153
  self.client = openai.OpenAI(
139
154
  api_key=api_key, base_url="https://api.groq.com/openai/v1"
140
155
  )
141
156
  self.model = model
157
+ self.reasoning_effort = normalize_reasoning_effort(reasoning_effort)
142
158
 
143
159
  def generate_commit_message(
144
160
  self, diff: str, prompt: str, max_tokens: int
@@ -151,6 +167,7 @@ class GroqProvider(AIProvider):
151
167
  ],
152
168
  max_tokens=max_tokens,
153
169
  temperature=0.7,
170
+ extra_body=reasoning_extra_body(self.reasoning_effort),
154
171
  )
155
172
  if not response or not response.choices:
156
173
  raise ValueError("Groq returned empty response (no choices).")
@@ -164,6 +181,7 @@ class OpenRouterProvider(AIProvider):
164
181
  self,
165
182
  api_key: str,
166
183
  model: str = "meta-llama/llama-3.3-70b-instruct:free",
184
+ reasoning_effort: str = "auto",
167
185
  ):
168
186
  if not openai:
169
187
  raise ImportError("openai not installed. Run: pip install openai")
@@ -176,6 +194,7 @@ class OpenRouterProvider(AIProvider):
176
194
  },
177
195
  )
178
196
  self.model = model
197
+ self.reasoning_effort = normalize_reasoning_effort(reasoning_effort)
179
198
 
180
199
  def generate_commit_message(
181
200
  self, diff: str, prompt: str, max_tokens: int
@@ -188,7 +207,7 @@ class OpenRouterProvider(AIProvider):
188
207
  ],
189
208
  max_tokens=max_tokens,
190
209
  temperature=0.7,
191
- extra_body={"transforms": ["middle-out"]},
210
+ extra_body={"transforms": ["middle-out"], **reasoning_extra_body(self.reasoning_effort)},
192
211
  extra_headers={
193
212
  "HTTP-Referer": "https://github.com/hordunlarmy/DevCommit",
194
213
  "X-Title": "DevCommit",
@@ -207,23 +226,30 @@ class OpenRouterProvider(AIProvider):
207
226
  class AnthropicProvider(AIProvider):
208
227
  """Anthropic Claude provider"""
209
228
 
210
- def __init__(self, api_key: str, model: str = "claude-3-haiku-20240307"):
229
+ def __init__(self, api_key: str, model: str = "claude-3-haiku-20240307", reasoning_effort: str = "auto"):
211
230
  if not anthropic:
212
231
  raise ImportError(
213
232
  "anthropic not installed. Run: pip install anthropic"
214
233
  )
215
234
  self.client = anthropic.Anthropic(api_key=api_key)
216
235
  self.model = model
236
+ self.reasoning_effort = normalize_reasoning_effort(reasoning_effort)
217
237
 
218
238
  def generate_commit_message(
219
239
  self, diff: str, prompt: str, max_tokens: int
220
240
  ) -> str:
221
- message = self.client.messages.create(
241
+ request = dict(
222
242
  model=self.model,
223
243
  max_tokens=max_tokens,
224
244
  system=prompt,
225
245
  messages=[{"role": "user", "content": diff}],
226
246
  )
247
+ if self.reasoning_effort == "none":
248
+ request["thinking"] = {"type": "disabled"}
249
+ elif self.reasoning_effort != "auto":
250
+ request["thinking"] = {"type": "adaptive"}
251
+ request["output_config"] = {"effort": self.reasoning_effort}
252
+ message = self.client.messages.create(**request)
227
253
  return message.content[0].text.strip()
228
254
 
229
255
 
@@ -231,7 +257,7 @@ class OllamaProvider(AIProvider):
231
257
  """Ollama local model provider"""
232
258
 
233
259
  def __init__(
234
- self, base_url: str = "http://localhost:11434", model: str = "llama3"
260
+ self, base_url: str = "http://localhost:11434", model: str = "llama3", reasoning_effort: str = "auto"
235
261
  ):
236
262
  if not requests:
237
263
  raise ImportError(
@@ -239,6 +265,7 @@ class OllamaProvider(AIProvider):
239
265
  )
240
266
  self.base_url = base_url.rstrip("/")
241
267
  self.model = model
268
+ self.reasoning_effort = normalize_reasoning_effort(reasoning_effort)
242
269
 
243
270
  def generate_commit_message(
244
271
  self, diff: str, prompt: str, max_tokens: int
@@ -250,6 +277,8 @@ class OllamaProvider(AIProvider):
250
277
  "stream": False,
251
278
  "options": {"temperature": 0.7, "num_predict": max_tokens},
252
279
  }
280
+ if self.reasoning_effort != "auto":
281
+ data["think"] = False if self.reasoning_effort == "none" else self.reasoning_effort
253
282
 
254
283
  response = requests.post(url, json=data, timeout=60)
255
284
  response.raise_for_status()
@@ -267,6 +296,7 @@ class CustomProvider(AIProvider):
267
296
  api_url: str,
268
297
  api_key: Optional[str] = None,
269
298
  model: str = "default",
299
+ reasoning_effort: str = "auto",
270
300
  ):
271
301
  if not openai:
272
302
  raise ImportError("openai not installed. Run: pip install openai")
@@ -282,6 +312,7 @@ class CustomProvider(AIProvider):
282
312
  api_key=api_key or "dummy-key", base_url=base_url
283
313
  )
284
314
  self.model = model
315
+ self.reasoning_effort = normalize_reasoning_effort(reasoning_effort)
285
316
 
286
317
  def generate_commit_message(
287
318
  self, diff: str, prompt: str, max_tokens: int
@@ -294,6 +325,7 @@ class CustomProvider(AIProvider):
294
325
  ],
295
326
  max_tokens=max_tokens,
296
327
  temperature=0.7,
328
+ extra_body=reasoning_extra_body(self.reasoning_effort),
297
329
  )
298
330
  if not response or not response.choices:
299
331
  raise ValueError("Custom API returned empty response (no choices).")
@@ -304,6 +336,7 @@ def get_ai_provider(config) -> AIProvider:
304
336
  """Factory function to get the appropriate AI provider based on config"""
305
337
 
306
338
  provider_name = config("AI_PROVIDER", default="gemini").lower()
339
+ reasoning_effort = config("REASONING_EFFORT", default="auto")
307
340
 
308
341
  if provider_name == "gemini":
309
342
  api_key = config("GEMINI_API_KEY", default=None)
@@ -313,6 +346,8 @@ def get_ai_provider(config) -> AIProvider:
313
346
  model = config("GEMINI_MODEL", default=None) or config(
314
347
  "MODEL_NAME", default="gemini-2.0-flash-exp"
315
348
  )
349
+ if normalize_reasoning_effort(reasoning_effort) != "auto":
350
+ raise ValueError("REASONING_EFFORT is unsupported by the legacy Gemini SDK; use auto")
316
351
  return GeminiProvider(api_key, model)
317
352
 
318
353
  elif provider_name == "openai":
@@ -322,7 +357,7 @@ def get_ai_provider(config) -> AIProvider:
322
357
  model = config("OPENAI_MODEL", default=None) or config(
323
358
  "MODEL_NAME", default="gpt-4o-mini"
324
359
  )
325
- return OpenAIProvider(api_key, model)
360
+ return OpenAIProvider(api_key, model, reasoning_effort)
326
361
 
327
362
  elif provider_name == "groq":
328
363
  api_key = config("GROQ_API_KEY", default=None)
@@ -331,7 +366,7 @@ def get_ai_provider(config) -> AIProvider:
331
366
  model = config("GROQ_MODEL", default=None) or config(
332
367
  "MODEL_NAME", default="llama-3.3-70b-versatile"
333
368
  )
334
- return GroqProvider(api_key, model)
369
+ return GroqProvider(api_key, model, reasoning_effort)
335
370
 
336
371
  elif provider_name == "openrouter":
337
372
  api_key = config("OPENROUTER_API_KEY", default=None)
@@ -340,7 +375,7 @@ def get_ai_provider(config) -> AIProvider:
340
375
  model = config("OPENROUTER_MODEL", default=None) or config(
341
376
  "MODEL_NAME", default="meta-llama/llama-3.3-70b-instruct:free"
342
377
  )
343
- return OpenRouterProvider(api_key, model)
378
+ return OpenRouterProvider(api_key, model, reasoning_effort)
344
379
 
345
380
  elif provider_name == "anthropic":
346
381
  api_key = config("ANTHROPIC_API_KEY", default=None)
@@ -349,14 +384,14 @@ def get_ai_provider(config) -> AIProvider:
349
384
  model = config("ANTHROPIC_MODEL", default=None) or config(
350
385
  "MODEL_NAME", default="claude-3-haiku-20240307"
351
386
  )
352
- return AnthropicProvider(api_key, model)
387
+ return AnthropicProvider(api_key, model, reasoning_effort)
353
388
 
354
389
  elif provider_name == "ollama":
355
390
  base_url = config("OLLAMA_BASE_URL", default="http://localhost:11434")
356
391
  model = config("OLLAMA_MODEL", default=None) or config(
357
392
  "MODEL_NAME", default="llama3"
358
393
  )
359
- return OllamaProvider(base_url, model)
394
+ return OllamaProvider(base_url, model, reasoning_effort)
360
395
 
361
396
  elif provider_name == "custom":
362
397
  api_url = config("CUSTOM_API_URL", default=None)
@@ -366,7 +401,7 @@ def get_ai_provider(config) -> AIProvider:
366
401
  model = config("CUSTOM_MODEL", default=None) or config(
367
402
  "MODEL_NAME", default="default"
368
403
  )
369
- return CustomProvider(api_url, api_key, model)
404
+ return CustomProvider(api_url, api_key, model, reasoning_effort)
370
405
 
371
406
  else:
372
407
  raise ValueError(
@@ -0,0 +1,246 @@
1
+ """Provider-aware model catalogue helpers."""
2
+
3
+ import json
4
+ from typing import Callable, Dict, List, Optional
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import urlencode
7
+ from urllib.request import Request, urlopen
8
+
9
+
10
+ Model = Dict[str, object]
11
+ Config = Callable[..., Optional[str]]
12
+
13
+
14
+ class ModelCatalogError(Exception):
15
+ """Raised when a provider's model catalogue cannot be read."""
16
+
17
+
18
+ def _request_json(
19
+ url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 10
20
+ ) -> Dict[str, object]:
21
+ request_headers = {"User-Agent": "DevCommit"}
22
+ if headers:
23
+ request_headers.update(headers)
24
+
25
+ try:
26
+ with urlopen(Request(url, headers=request_headers), timeout=timeout) as response:
27
+ payload = json.load(response)
28
+ except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error:
29
+ raise ModelCatalogError(f"Could not fetch models: {error}") from error
30
+
31
+ if not isinstance(payload, dict):
32
+ raise ModelCatalogError("The provider returned an unexpected models response.")
33
+ return payload
34
+
35
+
36
+ def _api_key(config: Config, key_name: str) -> str:
37
+ api_key = config(key_name, default=None)
38
+ if not api_key:
39
+ raise ModelCatalogError(f"{key_name} is not set.")
40
+ return api_key
41
+
42
+
43
+ def _model(
44
+ model_id: str,
45
+ name: Optional[str] = None,
46
+ context_length: Optional[object] = None,
47
+ availability: str = "Available",
48
+ price: str = "-",
49
+ ) -> Model:
50
+ return {
51
+ "id": model_id,
52
+ "name": name or model_id,
53
+ "context_length": context_length,
54
+ "availability": availability,
55
+ "price": price,
56
+ }
57
+
58
+
59
+ def _models_from_openai_response(payload: Dict[str, object]) -> List[Model]:
60
+ data = payload.get("data")
61
+ if not isinstance(data, list):
62
+ raise ModelCatalogError("The provider returned an unexpected models response.")
63
+
64
+ models = []
65
+ for item in data:
66
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
67
+ models.append(_model(item["id"], item.get("id")))
68
+ return models
69
+
70
+
71
+ def _format_openrouter_price(pricing: object) -> str:
72
+ if not isinstance(pricing, dict):
73
+ return "-"
74
+
75
+ try:
76
+ prompt = float(pricing.get("prompt", "0")) * 1_000_000
77
+ completion = float(pricing.get("completion", "0")) * 1_000_000
78
+ except (TypeError, ValueError):
79
+ return "-"
80
+ if prompt < 0 or completion < 0:
81
+ return "Variable"
82
+ return f"${prompt:g} in / ${completion:g} out per M"
83
+
84
+
85
+ def _fetch_openrouter_models(config: Config, timeout: int) -> List[Model]:
86
+ api_key = config("OPENROUTER_API_KEY", default=None)
87
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
88
+ payload = _request_json(
89
+ "https://openrouter.ai/api/v1/models", headers, timeout
90
+ )
91
+ data = payload.get("data")
92
+ if not isinstance(data, list):
93
+ raise ModelCatalogError("OpenRouter returned an unexpected models response.")
94
+
95
+ models = []
96
+ for item in data:
97
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
98
+ continue
99
+ model_id = item["id"]
100
+ is_free = model_id.endswith(":free") or model_id == "openrouter/free"
101
+ models.append(
102
+ _model(
103
+ model_id,
104
+ item.get("name"),
105
+ item.get("context_length"),
106
+ "Free" if is_free else "Paid",
107
+ "Free" if is_free else _format_openrouter_price(item.get("pricing")),
108
+ )
109
+ )
110
+ return models
111
+
112
+
113
+ def _fetch_gemini_models(config: Config, timeout: int) -> List[Model]:
114
+ headers = {"x-goog-api-key": _api_key(config, "GEMINI_API_KEY")}
115
+ page_token = None
116
+ models = []
117
+
118
+ while True:
119
+ params = {"pageSize": "1000"}
120
+ if page_token:
121
+ params["pageToken"] = page_token
122
+ payload = _request_json(
123
+ "https://generativelanguage.googleapis.com/v1beta/models?"
124
+ + urlencode(params),
125
+ headers,
126
+ timeout,
127
+ )
128
+ data = payload.get("models")
129
+ if not isinstance(data, list):
130
+ raise ModelCatalogError("Gemini returned an unexpected models response.")
131
+
132
+ for item in data:
133
+ if not isinstance(item, dict) or "generateContent" not in item.get(
134
+ "supportedGenerationMethods", []
135
+ ):
136
+ continue
137
+ resource_name = item.get("name")
138
+ if not isinstance(resource_name, str):
139
+ continue
140
+ models.append(
141
+ _model(
142
+ resource_name.removeprefix("models/"),
143
+ item.get("displayName"),
144
+ item.get("inputTokenLimit"),
145
+ )
146
+ )
147
+
148
+ page_token = payload.get("nextPageToken")
149
+ if not isinstance(page_token, str) or not page_token:
150
+ return models
151
+
152
+
153
+ def _fetch_anthropic_models(config: Config, timeout: int) -> List[Model]:
154
+ headers = {
155
+ "x-api-key": _api_key(config, "ANTHROPIC_API_KEY"),
156
+ "anthropic-version": "2023-06-01",
157
+ }
158
+ after_id = None
159
+ models = []
160
+
161
+ while True:
162
+ params = {"limit": "1000"}
163
+ if after_id:
164
+ params["after_id"] = after_id
165
+ payload = _request_json(
166
+ "https://api.anthropic.com/v1/models?" + urlencode(params),
167
+ headers,
168
+ timeout,
169
+ )
170
+ data = payload.get("data")
171
+ if not isinstance(data, list):
172
+ raise ModelCatalogError("Anthropic returned an unexpected models response.")
173
+
174
+ for item in data:
175
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
176
+ models.append(
177
+ _model(
178
+ item["id"], item.get("display_name"), item.get("max_input_tokens")
179
+ )
180
+ )
181
+
182
+ if not payload.get("has_more"):
183
+ return models
184
+ after_id = payload.get("last_id")
185
+ if not isinstance(after_id, str) or not after_id:
186
+ raise ModelCatalogError("Anthropic returned an invalid pagination cursor.")
187
+
188
+
189
+ def _fetch_ollama_models(config: Config, timeout: int) -> List[Model]:
190
+ base_url = config("OLLAMA_BASE_URL", default="http://localhost:11434")
191
+ payload = _request_json(base_url.rstrip("/") + "/api/tags", timeout=timeout)
192
+ data = payload.get("models")
193
+ if not isinstance(data, list):
194
+ raise ModelCatalogError("Ollama returned an unexpected models response.")
195
+
196
+ models = []
197
+ for item in data:
198
+ if not isinstance(item, dict):
199
+ continue
200
+ model_id = item.get("model") or item.get("name")
201
+ if isinstance(model_id, str):
202
+ details = item.get("details")
203
+ size = details.get("parameter_size") if isinstance(details, dict) else None
204
+ models.append(_model(model_id, item.get("name"), size, "Installed"))
205
+ return models
206
+
207
+
208
+ def _fetch_custom_models(config: Config, timeout: int) -> List[Model]:
209
+ base_url = config("CUSTOM_API_URL", default=None)
210
+ if not base_url:
211
+ raise ModelCatalogError("CUSTOM_API_URL is not set.")
212
+ api_key = config("CUSTOM_API_KEY", default=None)
213
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
214
+ return _models_from_openai_response(
215
+ _request_json(base_url.rstrip("/") + "/models", headers, timeout)
216
+ )
217
+
218
+
219
+ def fetch_models(provider: str, config: Config, timeout: int = 10) -> List[Model]:
220
+ """Fetch the models available from the configured AI provider."""
221
+ provider = provider.lower()
222
+ fetchers = {
223
+ "openrouter": _fetch_openrouter_models,
224
+ "gemini": _fetch_gemini_models,
225
+ "openai": lambda config, timeout: _models_from_openai_response(
226
+ _request_json(
227
+ "https://api.openai.com/v1/models",
228
+ {"Authorization": f"Bearer {_api_key(config, 'OPENAI_API_KEY')}"},
229
+ timeout,
230
+ )
231
+ ),
232
+ "groq": lambda config, timeout: _models_from_openai_response(
233
+ _request_json(
234
+ "https://api.groq.com/openai/v1/models",
235
+ {"Authorization": f"Bearer {_api_key(config, 'GROQ_API_KEY')}"},
236
+ timeout,
237
+ )
238
+ ),
239
+ "anthropic": _fetch_anthropic_models,
240
+ "ollama": _fetch_ollama_models,
241
+ "custom": _fetch_custom_models,
242
+ }
243
+ fetcher = fetchers.get(provider)
244
+ if not fetcher:
245
+ raise ModelCatalogError(f"Unsupported AI_PROVIDER: {provider}")
246
+ return sorted(fetcher(config, timeout), key=lambda model: str(model["id"]).casefold())
@@ -68,6 +68,9 @@ MAX_NO = 1
68
68
  # Options: general, conventional, etc.
69
69
  COMMIT_TYPE = conventional
70
70
 
71
+ # Reasoning: auto, none, low, medium, high, xhigh, max. Use none for faster commits.
72
+ REASONING_EFFORT = auto
73
+
71
74
  # Gemini model to use (default: gemini-1.5-flash)
72
75
  MODEL_NAME = gemini-1.5-flash
73
76
 
@@ -79,7 +82,7 @@ MODEL_NAME = gemini-1.5-flash
79
82
  COMMIT_MODE = auto
80
83
 
81
84
  # Files to exclude from diff (default: package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock)
82
- # Comma-separated list of file patterns
85
+ # Comma-separated patterns or absolute paths within one repository
83
86
  EXCLUDE_FILES = *.lock, dist/*, build/*, node_modules/*
84
87
 
85
88
  # Directory for changelog files (default: changelogs)
@@ -9,10 +9,12 @@ os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "1"
9
9
 
10
10
  from InquirerPy import get_style, inquirer
11
11
  from rich.console import Console
12
+ from rich.table import Table
12
13
 
13
14
  from devcommit.__version__ import __version__
14
15
  from devcommit.app.changelog import generate_changelog, save_changelog
15
16
  from devcommit.app.gemini_ai import generateCommitMessage
17
+ from devcommit.app.model_catalog import ModelCatalogError, fetch_models
16
18
  from devcommit.utils.git import (KnownError, assert_git_repo,
17
19
  generate_relation_grouping_prompt,
18
20
  get_detected_message, get_diff_for_files,
@@ -77,6 +79,49 @@ def main(flags: CommitFlag = None):
77
79
  Console().print(f"devcommit version {__version__}")
78
80
  return
79
81
 
82
+ if flags.get("models", False):
83
+ console = Console()
84
+ provider = config("AI_PROVIDER", default="gemini").lower()
85
+ try:
86
+ models = fetch_models(provider, config)
87
+ except ModelCatalogError as error:
88
+ console.print(f"[bold red]Error:[/bold red] {error}")
89
+ return
90
+
91
+ if flags.get("freeModels", False):
92
+ models = [model for model in models if model["availability"] == "Free"]
93
+ elif flags.get("paidModels", False):
94
+ models = [model for model in models if model["availability"] == "Paid"]
95
+
96
+ if not models:
97
+ console.print(f"No matching models are available from {provider}.")
98
+ return
99
+
100
+ table = Table(title=f"Available {provider.title()} Models")
101
+ table.add_column("Model", style="bold cyan")
102
+ table.add_column("ID", style="green")
103
+ table.add_column("Context", justify="right")
104
+ table.add_column("Access")
105
+ table.add_column("Price")
106
+ for model in models:
107
+ context_length = model["context_length"]
108
+ context = (
109
+ f"{context_length:,}"
110
+ if isinstance(context_length, int)
111
+ else str(context_length or "-")
112
+ )
113
+ table.add_row(
114
+ str(model["name"]),
115
+ str(model["id"]),
116
+ context,
117
+ str(model["availability"]),
118
+ str(model["price"]),
119
+ )
120
+
121
+ console.print(table)
122
+ console.print(f"\nSet one with: {provider.upper()}_MODEL='<model-id>'")
123
+ return
124
+
80
125
  try:
81
126
  assert_git_repo()
82
127
  console = Console()
@@ -35,7 +35,32 @@ def exclude_from_diff(path: str) -> str:
35
35
  Prepares a Git exclusion path string for the diff command.
36
36
  """
37
37
 
38
- return f':(exclude){path}'
38
+ return f':(top,exclude){path}'
39
+
40
+
41
+ def normalize_exclude_paths(paths: List[str], repo_root: str) -> List[str]:
42
+ """Convert absolute exclusions in this repository to root-relative paths."""
43
+ normalized_paths = []
44
+ resolved_root = os.path.realpath(repo_root)
45
+
46
+ for path in paths:
47
+ expanded_path = os.path.expanduser(path)
48
+ if not os.path.isabs(expanded_path):
49
+ normalized_paths.append(path)
50
+ continue
51
+
52
+ resolved_path = os.path.realpath(expanded_path)
53
+ try:
54
+ is_in_repo = os.path.commonpath(
55
+ [resolved_root, resolved_path]
56
+ ) == resolved_root
57
+ except ValueError:
58
+ is_in_repo = False
59
+
60
+ if is_in_repo:
61
+ normalized_paths.append(os.path.relpath(resolved_path, resolved_root))
62
+
63
+ return normalized_paths
39
64
 
40
65
 
41
66
  def get_default_excludes() -> List[str]:
@@ -70,9 +95,12 @@ def get_staged_diff(
70
95
  Gets the list of staged files and their diff, excluding specified files.
71
96
  """
72
97
  exclude_files = exclude_files or []
98
+ repo_root = assert_git_repo()
73
99
  diff_cached = ['git', 'diff', '--cached', '--diff-algorithm=minimal']
74
- excluded_from_diff = (
75
- [exclude_from_diff(f) for f in files_to_exclude + exclude_files])
100
+ excludes = normalize_exclude_paths(
101
+ files_to_exclude + exclude_files, repo_root
102
+ )
103
+ excluded_from_diff = [exclude_from_diff(path) for path in excludes]
76
104
 
77
105
  try:
78
106
  # Get the list of staged files excluding specified files
@@ -81,7 +109,8 @@ def get_staged_diff(
81
109
  check=True,
82
110
  stdout=subprocess.PIPE,
83
111
  stderr=subprocess.PIPE,
84
- text=True
112
+ text=True,
113
+ cwd=repo_root
85
114
  )
86
115
  files_result = (
87
116
  files.stdout.strip().split('\n') if files.stdout.strip() else []
@@ -95,7 +124,8 @@ def get_staged_diff(
95
124
  check=True,
96
125
  stdout=subprocess.PIPE,
97
126
  stderr=subprocess.PIPE,
98
- text=True
127
+ text=True,
128
+ cwd=repo_root
99
129
  )
100
130
  diff_result = diff.stdout.strip()
101
131
 
@@ -140,25 +170,21 @@ def get_diff_for_files(files: List[str], exclude_files: Optional[List[str]] = No
140
170
  Gets the diff for specific files.
141
171
  """
142
172
  exclude_files = exclude_files or []
143
-
144
- # Filter out excluded files from the list
145
- all_excluded = files_to_exclude + exclude_files
146
- filtered_files = [
147
- f for f in files
148
- if not any(f.endswith(excl.replace('*', '')) or excl.replace(':(exclude)', '') in f
149
- for excl in all_excluded)
150
- ]
151
-
152
- if not filtered_files:
153
- return ""
173
+ repo_root = assert_git_repo()
174
+ excludes = normalize_exclude_paths(
175
+ files_to_exclude + exclude_files, repo_root
176
+ )
177
+ excluded_from_diff = [exclude_from_diff(path) for path in excludes]
154
178
 
155
179
  try:
156
180
  diff = subprocess.run(
157
- ['git', 'diff', '--cached', '--diff-algorithm=minimal', '--'] + filtered_files,
181
+ ['git', 'diff', '--cached', '--diff-algorithm=minimal', '--']
182
+ + files + excluded_from_diff,
158
183
  check=True,
159
184
  stdout=subprocess.PIPE,
160
185
  stderr=subprocess.PIPE,
161
- text=True
186
+ text=True,
187
+ cwd=repo_root
162
188
  )
163
189
  return diff.stdout.strip()
164
190
  except subprocess.CalledProcessError:
@@ -12,6 +12,9 @@ class CommitFlag(TypedDict):
12
12
  files: List[str]
13
13
  push: bool
14
14
  changelog: bool
15
+ models: bool
16
+ freeModels: bool
17
+ paidModels: bool
15
18
  version: bool
16
19
  rawArgv: List[str]
17
20
 
@@ -58,6 +61,24 @@ def parse_arguments() -> CommitFlag:
58
61
  "--changelog", "-c", action="store_true",
59
62
  help="Generate changelog file from changes"
60
63
  )
64
+ parser.add_argument(
65
+ "--models",
66
+ action="store_true",
67
+ help="List models from the configured AI provider and exit",
68
+ )
69
+ model_filter = parser.add_mutually_exclusive_group()
70
+ model_filter.add_argument(
71
+ "--free",
72
+ dest="free_models",
73
+ action="store_true",
74
+ help="Only show free models with --models",
75
+ )
76
+ model_filter.add_argument(
77
+ "--paid",
78
+ dest="paid_models",
79
+ action="store_true",
80
+ help="Only show paid models with --models",
81
+ )
61
82
  parser.add_argument(
62
83
  "--version", "-v", action="store_true",
63
84
  help="Show version information"
@@ -77,6 +98,9 @@ def parse_arguments() -> CommitFlag:
77
98
  files=args.files or [],
78
99
  push=args.push,
79
100
  changelog=args.changelog,
101
+ models=args.models,
102
+ freeModels=args.free_models,
103
+ paidModels=args.paid_models,
80
104
  version=args.version,
81
105
  rawArgv=args.rawArgv,
82
106
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devcommit"
3
- version = "0.1.5.6"
3
+ version = "0.1.5.7"
4
4
  description = "AI-powered git commit message generator"
5
5
  readme = "README.md"
6
6
  license = {file = "COPYING"}
@@ -44,7 +44,7 @@ create-dcommit = "devcommit.create_config:create_dcommit"
44
44
 
45
45
  [tool.poetry]
46
46
  name = "devcommit"
47
- version = "0.1.5.6"
47
+ version = "0.1.5.7"
48
48
  description = "AI-powered git commit message generator"
49
49
  authors = ["Hordunlarmy <Hordunlarmy@gmail.com>"]
50
50
 
File without changes