shift-this-version 1.3.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.
@@ -0,0 +1,23 @@
1
+ """
2
+ shift-this-version: Smart SemVer Bumper driven by Code Diff & AI
3
+ """
4
+
5
+ from .analyzer import analyze, BumpAnalysis
6
+ from .updater import calculate_next_version, find_version_targets, apply_version_bump, VersionTarget
7
+ from .git_ops import get_filtered_diff, get_commits_since, get_latest_tag, get_diff_summary
8
+
9
+ __version__ = "1.3.0"
10
+
11
+ __all__ = [
12
+ "analyze",
13
+ "BumpAnalysis",
14
+ "calculate_next_version",
15
+ "find_version_targets",
16
+ "apply_version_bump",
17
+ "VersionTarget",
18
+ "get_filtered_diff",
19
+ "get_commits_since",
20
+ "get_latest_tag",
21
+ "get_diff_summary",
22
+ "__version__"
23
+ ]
@@ -0,0 +1,514 @@
1
+ import os
2
+ import json
3
+ import re
4
+ from typing import List, Literal, Optional, Tuple
5
+ import httpx
6
+ from pydantic import BaseModel, Field, model_validator
7
+ from typing import Any
8
+
9
+ class BumpAnalysis(BaseModel):
10
+ bump_type: Literal["major", "minor", "patch", "none"] = Field(
11
+ default="patch",
12
+ description="The SemVer bump level: major, minor, patch, or none"
13
+ )
14
+ confidence: float = Field(
15
+ default=1.0,
16
+ description="Confidence score between 0.0 and 1.0"
17
+ )
18
+ commit_message: str = Field(
19
+ default="",
20
+ description="Concise Conventional Commit message (e.g. 'feat: ...' or 'fix: ...') summarizing the code changes"
21
+ )
22
+ reasoning: str = Field(
23
+ default="AI completed SemVer analysis.",
24
+ description="Detailed explanation of why this bump level was chosen based on the diff and commits"
25
+ )
26
+ breaking_changes: List[str] = Field(
27
+ default_factory=list,
28
+ description="List of breaking changes identified, if any"
29
+ )
30
+ key_changes: List[str] = Field(
31
+ default_factory=list,
32
+ description="List of key changes, features, or bug fixes detected"
33
+ )
34
+
35
+ @model_validator(mode="before")
36
+ @classmethod
37
+ def normalize_input(cls, data: Any) -> Any:
38
+ if not isinstance(data, dict):
39
+ return data
40
+
41
+ # Check for provider error messages
42
+ if "error" in data:
43
+ err = data["error"]
44
+ err_msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
45
+ raise ValueError(f"AI Provider error: {err_msg}")
46
+
47
+ # Unwrap if inside nested containers: "analysis", "result", "data", "response", "semver"
48
+ for container_key in ["analysis", "result", "data", "response", "semver", "version_bump", "recommendation"]:
49
+ if container_key in data and isinstance(data[container_key], dict):
50
+ inner = data[container_key]
51
+ for k, v in inner.items():
52
+ data.setdefault(k, v)
53
+
54
+ # Normalize bump_type synonyms and aliases
55
+ if "bump_type" not in data or not data["bump_type"]:
56
+ for alt in ["bumpType", "bump", "type", "level", "semver_bump", "version_bump", "action", "recommendation"]:
57
+ if alt in data and isinstance(data[alt], str):
58
+ data["bump_type"] = data[alt]
59
+ break
60
+
61
+ if "bump_type" in data and isinstance(data["bump_type"], str):
62
+ val = data["bump_type"].lower().strip()
63
+ if "major" in val:
64
+ data["bump_type"] = "major"
65
+ elif "minor" in val:
66
+ data["bump_type"] = "minor"
67
+ elif "patch" in val:
68
+ data["bump_type"] = "patch"
69
+ elif "none" in val:
70
+ data["bump_type"] = "none"
71
+ else:
72
+ data["bump_type"] = "patch"
73
+ else:
74
+ data["bump_type"] = "patch"
75
+
76
+ # Normalize reasoning synonyms and aliases
77
+ if "reasoning" not in data or not data["reasoning"]:
78
+ for alt in ["reason", "explanation", "rationale", "description", "details", "summary", "justification", "notes"]:
79
+ if alt in data and data[alt]:
80
+ data["reasoning"] = str(data[alt])
81
+ break
82
+ if not data.get("reasoning"):
83
+ data["reasoning"] = "AI analyzed code diff and recommended this SemVer shift."
84
+
85
+ # Normalize commit_message synonyms
86
+ if "commit_message" not in data or not data["commit_message"]:
87
+ for alt in ["commitMessage", "commit_msg", "suggested_commit_message", "conventional_commit", "message"]:
88
+ if alt in data and data[alt]:
89
+ data["commit_message"] = str(data[alt])
90
+ break
91
+
92
+ # Normalize confidence
93
+ if "confidence" not in data:
94
+ for alt in ["confidence_score", "score"]:
95
+ if alt in data:
96
+ data["confidence"] = data[alt]
97
+ break
98
+ try:
99
+ conf = float(data.get("confidence", 1.0))
100
+ if conf > 1.0:
101
+ conf = conf / 100.0
102
+ data["confidence"] = max(0.0, min(1.0, conf))
103
+ except (ValueError, TypeError):
104
+ data["confidence"] = 0.95
105
+
106
+ # Normalize breaking_changes
107
+ if "breaking_changes" not in data:
108
+ for alt in ["breakingChanges", "breaking"]:
109
+ if alt in data:
110
+ data["breaking_changes"] = data[alt]
111
+ break
112
+ if isinstance(data.get("breaking_changes"), str):
113
+ data["breaking_changes"] = [data["breaking_changes"]]
114
+ elif not isinstance(data.get("breaking_changes"), list):
115
+ data["breaking_changes"] = []
116
+
117
+ # Normalize key_changes
118
+ if "key_changes" not in data:
119
+ for alt in ["keyChanges", "changes"]:
120
+ if alt in data:
121
+ data["key_changes"] = data[alt]
122
+ break
123
+ if isinstance(data.get("key_changes"), str):
124
+ data["key_changes"] = [data["key_changes"]]
125
+ elif not isinstance(data.get("key_changes"), list):
126
+ data["key_changes"] = []
127
+
128
+ return data
129
+
130
+ SYSTEM_PROMPT = """You are an expert software engineer and Semantic Versioning (SemVer 2.0.0) analyst.
131
+ Your task is to analyze the provided Git commit logs and code diff, and decide whether the next release should be:
132
+ - "major": Contains backwards-incompatible API changes, breaking changes, removed public endpoints/functions/classes/parameters, or major breaking redesigns.
133
+ - "minor": Adds new functionality or features in a backwards-compatible manner, or introduces new deprecations without removing old APIs.
134
+ - "patch": Backwards-compatible bug fixes, minor refactoring, dependency updates, internal optimizations, or documentation changes.
135
+ - "none": No functional code or behavior changes warranting a version increment.
136
+
137
+ Also formulate a clear, concise Conventional Commit message (e.g., "feat: ...", "fix: ...", "refactor: ...") that accurately summarizes the overall code diff.
138
+
139
+ Analyze with strict attention to public API contracts, function signatures, and exported variables.
140
+ You MUST output valid JSON matching this exact structure:
141
+ {
142
+ "bump_type": "major" | "minor" | "patch" | "none",
143
+ "confidence": 0.95,
144
+ "commit_message": "feat(core): concise Conventional Commit message summarizing the changes",
145
+ "reasoning": "Explanation of the decision",
146
+ "breaking_changes": ["detail of breaking change 1", ...],
147
+ "key_changes": ["summary of change 1", ...]
148
+ }
149
+ Output ONLY the JSON object. Do not wrap in markdown or include additional text.
150
+ """
151
+
152
+ from shift_this_version import config
153
+
154
+ def extract_json_from_text(text: str) -> dict:
155
+ """ทำความสะอาดและแปลงข้อความตอบกลับจาก LLM ให้เป็น JSON dict อย่างแม่นยำและทนทาน"""
156
+ if not text or not text.strip():
157
+ raise ValueError("Received empty response from AI model.")
158
+
159
+ cleaned = text.strip()
160
+
161
+ # 1. ลบ thinking block จาก reasoning models (<think>...</think>)
162
+ cleaned = re.sub(r"<think>[\s\S]*?</think>", "", cleaned, flags=re.IGNORECASE).strip()
163
+
164
+ # 2. ตรวจหา markdown code fence ```json ... ``` หรือ ``` ... ```
165
+ match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, flags=re.IGNORECASE)
166
+ if match:
167
+ candidate = match.group(1).strip()
168
+ try:
169
+ parsed = json.loads(candidate)
170
+ if isinstance(parsed, dict):
171
+ return parsed
172
+ elif isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
173
+ return parsed[0]
174
+ except Exception:
175
+ pass
176
+
177
+ # 3. ลอง parse ข้อความทั้งหมดโดยตรง
178
+ try:
179
+ parsed = json.loads(cleaned)
180
+ if isinstance(parsed, dict):
181
+ return parsed
182
+ elif isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
183
+ return parsed[0]
184
+ except Exception:
185
+ pass
186
+
187
+ # 4. หากมีข้อความเกริ่นนำหรือสรุปท้าย ให้หา outermost { ... }
188
+ first_brace = cleaned.find("{")
189
+ last_brace = cleaned.rfind("}")
190
+ if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
191
+ candidate = cleaned[first_brace:last_brace + 1]
192
+ try:
193
+ parsed = json.loads(candidate)
194
+ if isinstance(parsed, dict):
195
+ return parsed
196
+ elif isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
197
+ return parsed[0]
198
+ except Exception:
199
+ # ลองแก้ไข trailing commas
200
+ try:
201
+ fixed = re.sub(r",\s*([\]}])", r"\1", candidate)
202
+ parsed = json.loads(fixed)
203
+ if isinstance(parsed, dict):
204
+ return parsed
205
+ except Exception:
206
+ pass
207
+
208
+ snippet = cleaned[:200] + ("..." if len(cleaned) > 200 else "")
209
+ raise ValueError(f"Could not parse valid JSON from AI response: {snippet}")
210
+
211
+ def get_key_for_provider(provider: str, explicit_key: Optional[str] = None) -> Optional[str]:
212
+ """
213
+ ดึง API Key ตามลำดับความสำคัญ:
214
+ 1. ส่งผ่าน parameter/flag โดยตรง
215
+ 2. ค่าที่บันทึกไว้ใน ~/.shift-this-version/config.json
216
+ 3. Tool-specific Environment variable (เช่น SHIFT_GEMINI_API_KEY)
217
+ 4. General Environment variable (เช่น GEMINI_API_KEY)
218
+ """
219
+ if explicit_key:
220
+ return explicit_key
221
+
222
+ prov = provider.lower()
223
+ # 1. จาก config.json
224
+ configured = config.get_configured_key(prov)
225
+ if configured:
226
+ return configured
227
+
228
+ # 2. จาก Environment Variables
229
+ env_names = {
230
+ "gemini": ["SHIFT_GEMINI_API_KEY", "GEMINI_API_KEY"],
231
+ "anthropic": ["SHIFT_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"],
232
+ "openrouter": ["SHIFT_OPENROUTER_API_KEY", "OPENROUTER_API_KEY"],
233
+ "openai": ["SHIFT_OPENAI_API_KEY", "OPENAI_API_KEY"],
234
+ "deepseek": ["SHIFT_DEEPSEEK_API_KEY", "DEEPSEEK_API_KEY"],
235
+ "groq": ["SHIFT_GROQ_API_KEY", "GROQ_API_KEY"],
236
+ "custom": ["SHIFT_CUSTOM_API_KEY", "CUSTOM_API_KEY"],
237
+ }
238
+ for env_var in env_names.get(prov, []):
239
+ val = os.getenv(env_var)
240
+ if val:
241
+ return val
242
+ return None
243
+
244
+ def is_ollama_running(host: str = "http://localhost:11434") -> bool:
245
+ """Check if local Ollama daemon is reachable with a fast 1.0s timeout."""
246
+ try:
247
+ with httpx.Client(timeout=1.0) as client:
248
+ resp = client.get(f"{host.rstrip('/')}/api/tags")
249
+ return resp.status_code == 200
250
+ except Exception:
251
+ return False
252
+
253
+ def detect_default_provider() -> Tuple[Optional[str], Optional[str]]:
254
+ """ตรวจจับ provider และ key ที่พร้อมใช้งานอัตโนมัติ"""
255
+ # 1. ดูจาก default_provider ใน config.json
256
+ cfg_default = config.get_default_provider()
257
+ if cfg_default:
258
+ key = get_key_for_provider(cfg_default)
259
+ if key or cfg_default in ("ollama", "custom"):
260
+ return cfg_default, key
261
+
262
+ # 2. ตรวจเช็คทีละ provider ตามลำดับความนิยม
263
+ for prov in ["gemini", "anthropic", "openrouter", "deepseek", "groq", "openai"]:
264
+ key = get_key_for_provider(prov)
265
+ if key:
266
+ return prov, key
267
+
268
+ # 3. ตรวจสอบว่ามี Ollama รันอยู่จริงหรือไม่ ก่อนจะเลือกใช้งาน
269
+ ollama_host = config.get_configured_host("ollama") or os.getenv("OLLAMA_HOST", "http://localhost:11434")
270
+ if is_ollama_running(ollama_host):
271
+ return "ollama", ollama_host
272
+
273
+ # 4. หากไม่มี provider หรือ key ที่พร้อมใช้งานเลย คืน None
274
+ return None, None
275
+
276
+
277
+
278
+ def call_gemini(diff: str, commits: List[str], api_key: str, model: str = "gemini-2.5-flash") -> BumpAnalysis:
279
+ """เรียก Google Gemini REST API"""
280
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
281
+ payload = {
282
+ "system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
283
+ "contents": [{
284
+ "parts": [{
285
+ "text": f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
286
+ }]
287
+ }],
288
+ "generationConfig": {
289
+ "response_mime_type": "application/json",
290
+ "temperature": 0.1
291
+ }
292
+ }
293
+ with httpx.Client(timeout=60.0) as client:
294
+ resp = client.post(url, json=payload)
295
+ resp.raise_for_status()
296
+ data = resp.json()
297
+ raw_text = data["candidates"][0]["content"]["parts"][0]["text"]
298
+ parsed = extract_json_from_text(raw_text)
299
+ return BumpAnalysis(**parsed)
300
+
301
+ def call_anthropic(diff: str, commits: List[str], api_key: str, model: str = "claude-3-5-haiku-20241022") -> BumpAnalysis:
302
+ """เรียก Anthropic Claude Messages REST API"""
303
+ url = "https://api.anthropic.com/v1/messages"
304
+ headers = {
305
+ "x-api-key": api_key,
306
+ "anthropic-version": "2023-06-01",
307
+ "content-type": "application/json"
308
+ }
309
+ user_content = f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
310
+ payload = {
311
+ "model": model,
312
+ "max_tokens": 1024,
313
+ "system": SYSTEM_PROMPT,
314
+ "messages": [
315
+ {"role": "user", "content": user_content}
316
+ ],
317
+ "temperature": 0.1
318
+ }
319
+ with httpx.Client(timeout=60.0) as client:
320
+ resp = client.post(url, headers=headers, json=payload)
321
+ resp.raise_for_status()
322
+ data = resp.json()
323
+ raw_text = data["content"][0]["text"]
324
+ parsed = extract_json_from_text(raw_text)
325
+ return BumpAnalysis(**parsed)
326
+
327
+ def call_openai_compatible(
328
+ diff: str,
329
+ commits: List[str],
330
+ api_key: str,
331
+ base_url: str,
332
+ model: str,
333
+ extra_headers: Optional[dict] = None
334
+ ) -> BumpAnalysis:
335
+ """เรียก OpenAI-compatible API (รองรับ OpenAI, OpenRouter, DeepSeek, Groq, Custom)"""
336
+ headers = {
337
+ "Authorization": f"Bearer {api_key}",
338
+ "Content-Type": "application/json"
339
+ }
340
+ if extra_headers:
341
+ headers.update(extra_headers)
342
+
343
+ url = f"{base_url.rstrip('/')}/chat/completions"
344
+ user_content = f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
345
+ payload = {
346
+ "model": model,
347
+ "messages": [
348
+ {"role": "system", "content": SYSTEM_PROMPT},
349
+ {"role": "user", "content": user_content}
350
+ ],
351
+ "response_format": {"type": "json_object"},
352
+ "temperature": 0.1
353
+ }
354
+ with httpx.Client(timeout=60.0) as client:
355
+ resp = client.post(url, headers=headers, json=payload)
356
+ # ถ้า provider ไม่รองรับ response_format ให้ retry โดยเอา response_format ออก
357
+ if resp.status_code == 400 and "response_format" in resp.text.lower():
358
+ payload.pop("response_format", None)
359
+ resp = client.post(url, headers=headers, json=payload)
360
+
361
+ resp.raise_for_status()
362
+ data = resp.json()
363
+ if "error" in data:
364
+ err = data["error"]
365
+ err_msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
366
+ raise ValueError(f"AI Provider error: {err_msg}")
367
+
368
+ choices = data.get("choices", [])
369
+ if not choices:
370
+ raise ValueError(f"No completion choices returned by AI provider: {data}")
371
+
372
+ msg = choices[0].get("message", {})
373
+ raw_text = msg.get("content") or msg.get("reasoning_content") or ""
374
+ parsed = extract_json_from_text(raw_text)
375
+ return BumpAnalysis(**parsed)
376
+
377
+ def call_ollama(diff: str, commits: List[str], host: str = "http://localhost:11434", model: str = "llama3.2") -> BumpAnalysis:
378
+ """เรียก Ollama Local REST API"""
379
+ url = f"{host.rstrip('/')}/api/chat"
380
+ user_content = f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
381
+ payload = {
382
+ "model": model,
383
+ "messages": [
384
+ {"role": "system", "content": SYSTEM_PROMPT},
385
+ {"role": "user", "content": user_content}
386
+ ],
387
+ "format": "json",
388
+ "stream": False,
389
+ "options": {"temperature": 0.1}
390
+ }
391
+ with httpx.Client(timeout=90.0) as client:
392
+ resp = client.post(url, json=payload)
393
+ resp.raise_for_status()
394
+ data = resp.json()
395
+ raw_text = data["message"]["content"]
396
+ parsed = extract_json_from_text(raw_text)
397
+ return BumpAnalysis(**parsed)
398
+
399
+ def analyze(
400
+ diff: str,
401
+ commits: List[str],
402
+ provider: Optional[str] = None,
403
+ model: Optional[str] = None,
404
+ api_key: Optional[str] = None,
405
+ host: Optional[str] = None
406
+ ) -> BumpAnalysis:
407
+ """
408
+ ฟังก์ชันหลักสำหรับส่ง Diff ไปให้ AI วิเคราะห์
409
+ รองรับ: gemini, anthropic, openrouter, deepseek, groq, openai, ollama, custom
410
+ """
411
+ if not provider or provider == "auto":
412
+ detected_prov, detected_key = detect_default_provider()
413
+ if not detected_prov:
414
+ raise ValueError(
415
+ "No configured AI provider or active API key found. "
416
+ "Please run 'shift-this-version config' to select an AI provider and enter your API key."
417
+ )
418
+ provider = detected_prov
419
+ if not api_key:
420
+ api_key = detected_key
421
+
422
+ provider = provider.lower()
423
+ # ดึงค่า model และ host จาก config หากไม่ได้ระบุผ่าน CLI
424
+ chosen_model = model or config.get_configured_model(provider)
425
+ chosen_host = host or config.get_configured_host(provider)
426
+
427
+ # 1. กลุ่ม Direct Cloud Giants
428
+ if provider == "gemini":
429
+ key = get_key_for_provider("gemini", api_key)
430
+ if not key:
431
+ raise ValueError("Gemini API key is not found. Run 'shift-this-version config' or set GEMINI_API_KEY.")
432
+ target_model = chosen_model or "gemini-2.5-flash"
433
+ return call_gemini(diff, commits, api_key=key, model=target_model)
434
+
435
+ elif provider == "anthropic":
436
+ key = get_key_for_provider("anthropic", api_key)
437
+ if not key:
438
+ raise ValueError("Anthropic API key is not found. Run 'shift-this-version config' or set ANTHROPIC_API_KEY.")
439
+ target_model = chosen_model or "claude-3-5-haiku-20241022"
440
+ return call_anthropic(diff, commits, api_key=key, model=target_model)
441
+
442
+ elif provider == "openai":
443
+ key = get_key_for_provider("openai", api_key)
444
+ if not key:
445
+ raise ValueError("OpenAI API key is not found. Run 'shift-this-version config' or set OPENAI_API_KEY.")
446
+ target_model = chosen_model or "gpt-4o-mini"
447
+ return call_openai_compatible(
448
+ diff, commits, api_key=key,
449
+ base_url="https://api.openai.com/v1",
450
+ model=target_model
451
+ )
452
+
453
+ # 2. กลุ่ม High-Speed & Value Powerhouses
454
+ elif provider == "deepseek":
455
+ key = get_key_for_provider("deepseek", api_key)
456
+ if not key:
457
+ raise ValueError("DeepSeek API key is not found. Run 'shift-this-version config' or set DEEPSEEK_API_KEY.")
458
+ target_model = chosen_model or "deepseek-chat"
459
+ return call_openai_compatible(
460
+ diff, commits, api_key=key,
461
+ base_url="https://api.deepseek.com/v1",
462
+ model=target_model
463
+ )
464
+
465
+ elif provider == "groq":
466
+ key = get_key_for_provider("groq", api_key)
467
+ if not key:
468
+ raise ValueError("Groq API key is not found. Run 'shift-this-version config' or set GROQ_API_KEY.")
469
+ target_model = chosen_model or "llama-3.3-70b-versatile"
470
+ return call_openai_compatible(
471
+ diff, commits, api_key=key,
472
+ base_url="https://api.groq.com/openai/v1",
473
+ model=target_model
474
+ )
475
+
476
+ # 3. กลุ่ม Universal Hub (OpenRouter)
477
+ elif provider == "openrouter":
478
+ key = get_key_for_provider("openrouter", api_key)
479
+ if not key:
480
+ raise ValueError("OpenRouter API key is not found. Run 'shift-this-version config' or set OPENROUTER_API_KEY.")
481
+ target_model = chosen_model or "google/gemini-2.0-flash-001"
482
+ return call_openai_compatible(
483
+ diff, commits, api_key=key,
484
+ base_url="https://openrouter.ai/api/v1",
485
+ model=target_model,
486
+ extra_headers={"HTTP-Referer": "https://github.com/shift-this-version", "X-Title": "shift-this-version"}
487
+ )
488
+
489
+ # 4. กลุ่ม Local & Self-Hosted
490
+ elif provider == "ollama":
491
+ target_host = chosen_host or os.getenv("OLLAMA_HOST", "http://localhost:11434")
492
+ if not is_ollama_running(target_host):
493
+ raise ConnectionError(
494
+ f"Could not connect to Ollama at '{target_host}'. "
495
+ "Please verify that the Ollama service is running, or run 'shift-this-version config' to switch providers."
496
+ )
497
+ target_model = chosen_model or "llama3.2"
498
+ return call_ollama(diff, commits, host=target_host, model=target_model)
499
+
500
+ elif provider in ("custom", "localai"):
501
+ base_url = chosen_host or os.getenv("CUSTOM_API_BASE", "http://localhost:1234/v1")
502
+ target_model = chosen_model or "local-model"
503
+ key = get_key_for_provider("custom", api_key) or "not-needed"
504
+ return call_openai_compatible(
505
+ diff, commits, api_key=key,
506
+ base_url=base_url,
507
+ model=target_model
508
+ )
509
+
510
+ else:
511
+ raise ValueError(
512
+ f"Unsupported provider: '{provider}'. Choose from: gemini, anthropic, openai, deepseek, groq, openrouter, ollama, custom."
513
+ )
514
+