@gaia-minds/assistant-cli 0.1.1 → 0.2.0

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.
@@ -3,7 +3,7 @@
3
3
 
4
4
  This is the reasoning core of an autonomous agent that:
5
5
  1. Gathers state from the repository and GitHub
6
- 2. Asks Claude what to do (with Constitution as system constraint)
6
+ 2. Asks the configured reasoning provider what to do
7
7
  3. Checks alignment of proposed actions
8
8
  4. Executes approved actions
9
9
  5. Writes memory (decisions, lessons, state)
@@ -25,6 +25,8 @@ import logging
25
25
  import os
26
26
  import sys
27
27
  import time
28
+ import urllib.error
29
+ import urllib.request
28
30
  from datetime import datetime, timezone
29
31
  from pathlib import Path
30
32
  from typing import Any, Dict, List, Optional
@@ -41,7 +43,6 @@ sys.path.insert(0, str(SCRIPT_DIR))
41
43
  from agent_alignment import ( # noqa: E402
42
44
  AlignmentResult,
43
45
  check_alignment,
44
- classify_risk,
45
46
  load_constitution,
46
47
  )
47
48
  from agent_actions import ( # noqa: E402
@@ -118,6 +119,11 @@ DEFAULT_BUDGET_POLICY: Dict[str, Any] = {
118
119
  "hard_cycle_token_cap": 12000,
119
120
  }
120
121
 
122
+ DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929"
123
+ DEFAULT_OPENAI_MODEL = "gpt-4.1-mini"
124
+ DEFAULT_OPENROUTER_MODEL = "openrouter/auto"
125
+ SUPPORTED_REASONING_PROVIDERS = {"anthropic", "openai", "openrouter"}
126
+
121
127
 
122
128
  # ---------------------------------------------------------------------------
123
129
  # Config
@@ -148,6 +154,181 @@ def load_config(path: Path = CONFIG_PATH) -> Dict[str, Any]:
148
154
  return config
149
155
 
150
156
 
157
+ def _resolve_reasoning_provider(config: Dict[str, Any]) -> str:
158
+ """Resolve reasoning provider from env override or config."""
159
+ override = os.environ.get("GAIA_REASONING_PROVIDER", "").strip().lower()
160
+ if override:
161
+ if override in SUPPORTED_REASONING_PROVIDERS:
162
+ return override
163
+ log.warning(
164
+ "Unsupported GAIA_REASONING_PROVIDER=%s; falling back to config/default",
165
+ override,
166
+ )
167
+
168
+ reasoning_cfg = config.get("reasoning", {})
169
+ if not isinstance(reasoning_cfg, dict):
170
+ reasoning_cfg = {}
171
+ configured = str(reasoning_cfg.get("provider", "anthropic")).strip().lower()
172
+ if configured in SUPPORTED_REASONING_PROVIDERS:
173
+ return configured
174
+ return "anthropic"
175
+
176
+
177
+ def _resolve_reasoning_model(config: Dict[str, Any], provider: str) -> str:
178
+ """Resolve reasoning model from env overrides and config."""
179
+ direct_override = os.environ.get("GAIA_REASONING_MODEL", "").strip()
180
+ if direct_override:
181
+ return direct_override
182
+
183
+ reasoning = config.get("reasoning", {})
184
+ reasoning = reasoning if isinstance(reasoning, dict) else {}
185
+ models_cfg = reasoning.get("models", {})
186
+ models_cfg = models_cfg if isinstance(models_cfg, dict) else {}
187
+
188
+ if provider == "openai":
189
+ env_model = os.environ.get("OPENAI_MODEL", "").strip()
190
+ if env_model:
191
+ return env_model
192
+
193
+ configured_openai_model = str(models_cfg.get("openai", "")).strip()
194
+ if configured_openai_model:
195
+ return configured_openai_model
196
+
197
+ openai_cfg = reasoning.get("openai", {})
198
+ if isinstance(openai_cfg, dict):
199
+ openai_model = str(openai_cfg.get("model", "")).strip()
200
+ if openai_model:
201
+ return openai_model
202
+
203
+ legacy_model = str(reasoning.get("model", "")).strip()
204
+ if legacy_model and legacy_model != DEFAULT_ANTHROPIC_MODEL:
205
+ return legacy_model
206
+
207
+ return DEFAULT_OPENAI_MODEL
208
+
209
+ if provider == "openrouter":
210
+ env_model = os.environ.get("OPENROUTER_MODEL", "").strip()
211
+ if env_model:
212
+ return env_model
213
+
214
+ configured_openrouter_model = str(models_cfg.get("openrouter", "")).strip()
215
+ if configured_openrouter_model:
216
+ return configured_openrouter_model
217
+
218
+ openrouter_cfg = reasoning.get("openrouter", {})
219
+ if isinstance(openrouter_cfg, dict):
220
+ openrouter_model = str(openrouter_cfg.get("model", "")).strip()
221
+ if openrouter_model:
222
+ return openrouter_model
223
+
224
+ legacy_model = str(reasoning.get("model", "")).strip()
225
+ if legacy_model and legacy_model != DEFAULT_ANTHROPIC_MODEL:
226
+ return legacy_model
227
+
228
+ return DEFAULT_OPENROUTER_MODEL
229
+
230
+ env_model = os.environ.get("ANTHROPIC_MODEL", "").strip()
231
+ if env_model:
232
+ return env_model
233
+
234
+ configured_anthropic_model = str(models_cfg.get("anthropic", "")).strip()
235
+ if configured_anthropic_model:
236
+ return configured_anthropic_model
237
+
238
+ legacy_model = str(reasoning.get("model", "")).strip()
239
+ return legacy_model or DEFAULT_ANTHROPIC_MODEL
240
+
241
+
242
+ def _resolve_openai_runtime(config: Dict[str, Any]) -> Dict[str, Any]:
243
+ """Resolve OpenAI runtime settings from config and env."""
244
+ reasoning = config.get("reasoning", {})
245
+ reasoning = reasoning if isinstance(reasoning, dict) else {}
246
+ openai_cfg = reasoning.get("openai", {})
247
+ openai_cfg = openai_cfg if isinstance(openai_cfg, dict) else {}
248
+
249
+ base_url = str(
250
+ os.environ.get(
251
+ "OPENAI_BASE_URL",
252
+ openai_cfg.get("base_url", "https://api.openai.com/v1"),
253
+ )
254
+ ).strip()
255
+ if not base_url:
256
+ base_url = "https://api.openai.com/v1"
257
+ base_url = base_url.rstrip("/")
258
+
259
+ timeout_raw = os.environ.get(
260
+ "OPENAI_TIMEOUT_SECONDS",
261
+ str(openai_cfg.get("timeout_seconds", 120)),
262
+ )
263
+ try:
264
+ timeout_seconds = int(str(timeout_raw).strip())
265
+ except ValueError:
266
+ timeout_seconds = 120
267
+ if timeout_seconds < 1:
268
+ timeout_seconds = 120
269
+
270
+ return {
271
+ "api_key": os.environ.get("OPENAI_API_KEY", "").strip(),
272
+ "base_url": base_url,
273
+ "timeout_seconds": timeout_seconds,
274
+ }
275
+
276
+
277
+ def _resolve_openrouter_runtime(config: Dict[str, Any]) -> Dict[str, Any]:
278
+ """Resolve OpenRouter runtime settings from config and env."""
279
+ reasoning = config.get("reasoning", {})
280
+ reasoning = reasoning if isinstance(reasoning, dict) else {}
281
+ openrouter_cfg = reasoning.get("openrouter", {})
282
+ openrouter_cfg = openrouter_cfg if isinstance(openrouter_cfg, dict) else {}
283
+
284
+ base_url = str(
285
+ os.environ.get(
286
+ "OPENROUTER_BASE_URL",
287
+ openrouter_cfg.get("base_url", "https://openrouter.ai/api/v1"),
288
+ )
289
+ ).strip()
290
+ if not base_url:
291
+ base_url = "https://openrouter.ai/api/v1"
292
+ base_url = base_url.rstrip("/")
293
+
294
+ app_name = str(
295
+ os.environ.get(
296
+ "OPENROUTER_APP_NAME",
297
+ openrouter_cfg.get("app_name", "gaia-minds-agent"),
298
+ )
299
+ ).strip()
300
+ if not app_name:
301
+ app_name = "gaia-minds-agent"
302
+
303
+ app_url = str(
304
+ os.environ.get(
305
+ "OPENROUTER_APP_URL",
306
+ openrouter_cfg.get("app_url", "https://github.com/Gaia-minds/gaia-minds"),
307
+ )
308
+ ).strip()
309
+ if not app_url:
310
+ app_url = "https://github.com/Gaia-minds/gaia-minds"
311
+
312
+ timeout_raw = os.environ.get(
313
+ "OPENROUTER_TIMEOUT_SECONDS",
314
+ str(openrouter_cfg.get("timeout_seconds", 120)),
315
+ )
316
+ try:
317
+ timeout_seconds = int(str(timeout_raw).strip())
318
+ except ValueError:
319
+ timeout_seconds = 120
320
+ if timeout_seconds < 1:
321
+ timeout_seconds = 120
322
+
323
+ return {
324
+ "api_key": os.environ.get("OPENROUTER_API_KEY", "").strip(),
325
+ "base_url": base_url,
326
+ "app_name": app_name,
327
+ "app_url": app_url,
328
+ "timeout_seconds": timeout_seconds,
329
+ }
330
+
331
+
151
332
  # ---------------------------------------------------------------------------
152
333
  # Memory
153
334
  # ---------------------------------------------------------------------------
@@ -390,7 +571,7 @@ def action_allowed_in_track(action_type: str, active_track: str, config: Dict[st
390
571
 
391
572
 
392
573
  # ---------------------------------------------------------------------------
393
- # Claude reasoning
574
+ # Reasoning
394
575
  # ---------------------------------------------------------------------------
395
576
 
396
577
  SYSTEM_PROMPT_TEMPLATE = """\
@@ -482,19 +663,168 @@ def state_to_summary(state: RepoState) -> Dict[str, Any]:
482
663
  }
483
664
 
484
665
 
485
- def ask_claude_for_plan(
666
+ def _strip_markdown_fences(text: str) -> str:
667
+ """Remove markdown code fences from LLM text responses."""
668
+ out = text.strip()
669
+ if out.startswith("```"):
670
+ lines = out.splitlines()
671
+ lines = [line for line in lines if not line.strip().startswith("```")]
672
+ out = "\n".join(lines)
673
+ return out.strip()
674
+
675
+
676
+ def _extract_anthropic_text(response: Any) -> str:
677
+ """Extract plain text from an Anthropic response object."""
678
+ text = ""
679
+ for block in response.content:
680
+ if hasattr(block, "text"):
681
+ text += block.text
682
+ return text
683
+
684
+
685
+ def _extract_chat_completion_text(payload: Dict[str, Any], provider_name: str) -> str:
686
+ """Extract assistant message text from chat completion payload."""
687
+ choices = payload.get("choices", [])
688
+ if not isinstance(choices, list) or not choices:
689
+ raise ValueError(f"{provider_name} response missing choices")
690
+ message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
691
+ if not isinstance(message, dict):
692
+ raise ValueError(f"{provider_name} response has invalid message payload")
693
+ content = message.get("content", "")
694
+ if isinstance(content, str):
695
+ return content
696
+ if isinstance(content, list):
697
+ parts: List[str] = []
698
+ for item in content:
699
+ if isinstance(item, dict):
700
+ text_value = item.get("text")
701
+ if isinstance(text_value, str):
702
+ parts.append(text_value)
703
+ return "".join(parts)
704
+ raise ValueError(f"{provider_name} response content is not text")
705
+
706
+
707
+ def _openai_chat_completion(
708
+ client: Dict[str, Any],
709
+ model: str,
710
+ system_prompt: str,
711
+ user_prompt: str,
712
+ max_tokens: int,
713
+ temperature: float,
714
+ ) -> str:
715
+ """Call OpenAI chat completions API and return text response."""
716
+ endpoint = f"{client['base_url']}/chat/completions"
717
+ payload = {
718
+ "model": model,
719
+ "messages": [
720
+ {"role": "system", "content": system_prompt},
721
+ {"role": "user", "content": user_prompt},
722
+ ],
723
+ "max_tokens": max_tokens,
724
+ "temperature": temperature,
725
+ }
726
+ headers = {
727
+ "Authorization": f"Bearer {client['api_key']}",
728
+ "Content-Type": "application/json",
729
+ }
730
+ request = urllib.request.Request(
731
+ endpoint,
732
+ data=json.dumps(payload).encode("utf-8"),
733
+ headers=headers,
734
+ method="POST",
735
+ )
736
+
737
+ try:
738
+ with urllib.request.urlopen(request, timeout=int(client["timeout_seconds"])) as response:
739
+ raw = response.read().decode("utf-8")
740
+ except urllib.error.HTTPError as exc:
741
+ details = exc.read().decode("utf-8", errors="replace")
742
+ raise RuntimeError(f"OpenAI API returned HTTP {exc.code}: {details[:400]}") from exc
743
+ except urllib.error.URLError as exc:
744
+ raise RuntimeError(f"OpenAI request failed: {exc}") from exc
745
+
746
+ try:
747
+ parsed = json.loads(raw)
748
+ except json.JSONDecodeError as exc:
749
+ raise RuntimeError("OpenAI API returned non-JSON response") from exc
750
+
751
+ return _extract_chat_completion_text(parsed, "OpenAI")
752
+
753
+
754
+ def _openrouter_chat_completion(
755
+ client: Dict[str, Any],
756
+ model: str,
757
+ system_prompt: str,
758
+ user_prompt: str,
759
+ max_tokens: int,
760
+ temperature: float,
761
+ ) -> str:
762
+ """Call OpenRouter chat completions API and return text response."""
763
+ endpoint = f"{client['base_url']}/chat/completions"
764
+ payload = {
765
+ "model": model,
766
+ "messages": [
767
+ {"role": "system", "content": system_prompt},
768
+ {"role": "user", "content": user_prompt},
769
+ ],
770
+ "max_tokens": max_tokens,
771
+ "temperature": temperature,
772
+ }
773
+ headers = {
774
+ "Authorization": f"Bearer {client['api_key']}",
775
+ "Content-Type": "application/json",
776
+ "HTTP-Referer": client["app_url"],
777
+ "X-Title": client["app_name"],
778
+ }
779
+ request = urllib.request.Request(
780
+ endpoint,
781
+ data=json.dumps(payload).encode("utf-8"),
782
+ headers=headers,
783
+ method="POST",
784
+ )
785
+
786
+ try:
787
+ with urllib.request.urlopen(request, timeout=int(client["timeout_seconds"])) as response:
788
+ raw = response.read().decode("utf-8")
789
+ except urllib.error.HTTPError as exc:
790
+ details = exc.read().decode("utf-8", errors="replace")
791
+ raise RuntimeError(f"OpenRouter API returned HTTP {exc.code}: {details[:400]}") from exc
792
+ except urllib.error.URLError as exc:
793
+ raise RuntimeError(f"OpenRouter request failed: {exc}") from exc
794
+
795
+ try:
796
+ parsed = json.loads(raw)
797
+ except json.JSONDecodeError as exc:
798
+ raise RuntimeError("OpenRouter API returned non-JSON response") from exc
799
+
800
+ return _extract_chat_completion_text(parsed, "OpenRouter")
801
+
802
+
803
+ def ask_model_for_plan(
486
804
  client: Any,
487
805
  config: Dict[str, Any],
488
806
  state: RepoState,
489
807
  memory: Dict[str, Any],
490
808
  constitution: str,
491
809
  active_track: str,
810
+ provider: str,
811
+ model: str,
492
812
  ) -> Dict[str, Any]:
493
- """Ask Claude to analyze state and propose actions."""
813
+ """Ask the configured reasoning provider to analyze state and propose actions."""
494
814
  reasoning_config = config.get("reasoning", {})
495
- model = reasoning_config.get("model", "claude-sonnet-4-5-20250929")
815
+ reasoning_config = reasoning_config if isinstance(reasoning_config, dict) else {}
496
816
  max_tokens = reasoning_config.get("max_tokens", 4096)
497
817
  temperature = reasoning_config.get("temperature", 0.3)
818
+ try:
819
+ max_tokens_int = int(max_tokens)
820
+ except (TypeError, ValueError):
821
+ max_tokens_int = 4096
822
+ if max_tokens_int < 1:
823
+ max_tokens_int = 4096
824
+ try:
825
+ temperature_float = float(temperature)
826
+ except (TypeError, ValueError):
827
+ temperature_float = 0.3
498
828
 
499
829
  system_prompt = SYSTEM_PROMPT_TEMPLATE.format(constitution=constitution)
500
830
 
@@ -513,42 +843,62 @@ def ask_claude_for_plan(
513
843
  budget_policy_json=json.dumps(budget_policy, indent=2),
514
844
  )
515
845
 
516
- log.info("Asking Claude (%s) for a plan...", model)
846
+ log.info("Asking reasoning provider '%s' (%s) for a plan...", provider, model)
517
847
  log.debug("System prompt: %d chars, User prompt: %d chars", len(system_prompt), len(user_prompt))
518
848
 
519
- response = client.messages.create(
520
- model=model,
521
- max_tokens=max_tokens,
522
- temperature=temperature,
523
- system=system_prompt,
524
- messages=[{"role": "user", "content": user_prompt}],
525
- )
526
-
527
- # Extract text from response
528
- text = ""
529
- for block in response.content:
530
- if hasattr(block, "text"):
531
- text += block.text
849
+ text: str
850
+ try:
851
+ if provider == "anthropic":
852
+ response = client.messages.create(
853
+ model=model,
854
+ max_tokens=max_tokens_int,
855
+ temperature=temperature_float,
856
+ system=system_prompt,
857
+ messages=[{"role": "user", "content": user_prompt}],
858
+ )
859
+ text = _extract_anthropic_text(response)
860
+ elif provider == "openai":
861
+ if not isinstance(client, dict):
862
+ raise RuntimeError("OpenAI client is misconfigured")
863
+ text = _openai_chat_completion(
864
+ client=client,
865
+ model=model,
866
+ system_prompt=system_prompt,
867
+ user_prompt=user_prompt,
868
+ max_tokens=max_tokens_int,
869
+ temperature=temperature_float,
870
+ )
871
+ elif provider == "openrouter":
872
+ if not isinstance(client, dict):
873
+ raise RuntimeError("OpenRouter client is misconfigured")
874
+ text = _openrouter_chat_completion(
875
+ client=client,
876
+ model=model,
877
+ system_prompt=system_prompt,
878
+ user_prompt=user_prompt,
879
+ max_tokens=max_tokens_int,
880
+ temperature=temperature_float,
881
+ )
882
+ else:
883
+ raise RuntimeError(f"Unsupported reasoning provider: {provider}")
884
+ except Exception as exc:
885
+ log.error("Reasoning request failed (%s/%s): %s", provider, model, exc)
886
+ return {"reasoning": f"Reasoning request failed: {exc}", "actions": []}
532
887
 
533
- # Parse JSON from response
534
- # Handle case where Claude wraps in code fences
535
- text = text.strip()
536
- if text.startswith("```"):
537
- # Remove code fences
538
- lines = text.splitlines()
539
- lines = [l for l in lines if not l.strip().startswith("```")]
540
- text = "\n".join(lines)
888
+ # Parse JSON from response text.
889
+ text = _strip_markdown_fences(text)
541
890
 
542
891
  try:
543
892
  plan = json.loads(text)
544
893
  except json.JSONDecodeError as exc:
545
- log.error("Failed to parse Claude's response as JSON: %s", exc)
894
+ log.error("Failed to parse reasoning response as JSON: %s", exc)
546
895
  log.error("Raw response: %s", text[:500])
547
896
  plan = {"reasoning": f"Failed to parse response: {exc}", "actions": []}
548
897
 
549
898
  actions = plan.get("actions", [])
550
899
  log.info(
551
- "Claude proposed %d action(s): %s",
900
+ "Reasoning provider '%s' proposed %d action(s): %s",
901
+ provider,
552
902
  len(actions),
553
903
  ", ".join(a.get("type", "?") for a in actions) or "(none)",
554
904
  )
@@ -566,6 +916,8 @@ def run_cycle(
566
916
  config: Dict[str, Any],
567
917
  client: Any,
568
918
  cycle_number: int,
919
+ reasoning_provider: str,
920
+ reasoning_model: str,
569
921
  dry_run: bool = False,
570
922
  ) -> List[ActionResult]:
571
923
  """Run one complete agent cycle."""
@@ -590,12 +942,30 @@ def run_cycle(
590
942
  # 3. Load constitution
591
943
  constitution = load_constitution(repo_root)
592
944
 
593
- # 4. Ask Claude for a plan
945
+ # 4. Ask reasoning provider for a plan
594
946
  if client is None:
595
- log.info("No Anthropic client available -- skipping Claude reasoning (dry-run)")
596
- plan = {"reasoning": f"No API client (dry-run without SDK/key), active_track={active_track}", "actions": []}
947
+ log.info(
948
+ "No API client available for provider '%s' -- skipping reasoning (dry-run)",
949
+ reasoning_provider,
950
+ )
951
+ plan = {
952
+ "reasoning": (
953
+ f"No API client (dry-run without provider credentials), "
954
+ f"provider={reasoning_provider}, active_track={active_track}"
955
+ ),
956
+ "actions": [],
957
+ }
597
958
  else:
598
- plan = ask_claude_for_plan(client, config, state, memory, constitution, active_track)
959
+ plan = ask_model_for_plan(
960
+ client=client,
961
+ config=config,
962
+ state=state,
963
+ memory=memory,
964
+ constitution=constitution,
965
+ active_track=active_track,
966
+ provider=reasoning_provider,
967
+ model=reasoning_model,
968
+ )
599
969
 
600
970
  actions = plan.get("actions", [])
601
971
  if not actions:
@@ -632,8 +1002,8 @@ def run_cycle(
632
1002
  action,
633
1003
  constitution,
634
1004
  json.dumps(memory.get("recent_decisions", [])[-5:]),
635
- client=client if _HAS_ANTHROPIC else None,
636
- model=config.get("reasoning", {}).get("model", "claude-sonnet-4-5-20250929"),
1005
+ client=client if reasoning_provider == "anthropic" and _HAS_ANTHROPIC else None,
1006
+ model=reasoning_model,
637
1007
  )
638
1008
 
639
1009
  log.info(
@@ -817,23 +1187,6 @@ def main() -> int:
817
1187
  level = logging.DEBUG if args.verbose else logging.INFO
818
1188
  logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, stream=sys.stderr)
819
1189
 
820
- # Check for anthropic SDK (not needed in dry-run)
821
- if not _HAS_ANTHROPIC and not args.dry_run:
822
- log.error(
823
- "The 'anthropic' package is required. Install it:\n"
824
- " pip install anthropic\n"
825
- "Or: pip install -r requirements.txt"
826
- )
827
- return 1
828
-
829
- # Check for API key (not needed in dry-run)
830
- if not os.environ.get("ANTHROPIC_API_KEY") and not args.dry_run:
831
- log.error(
832
- "ANTHROPIC_API_KEY environment variable is not set.\n"
833
- " export ANTHROPIC_API_KEY='your-key-here'"
834
- )
835
- return 1
836
-
837
1190
  # Load config
838
1191
  config_path = Path(args.config)
839
1192
  if not config_path.exists():
@@ -841,18 +1194,89 @@ def main() -> int:
841
1194
  return 1
842
1195
 
843
1196
  config = load_config(config_path)
1197
+ if not isinstance(config, dict):
1198
+ log.error("Config file is invalid: expected a YAML mapping at %s", config_path)
1199
+ return 1
1200
+
1201
+ reasoning_provider = _resolve_reasoning_provider(config)
1202
+ reasoning_model = _resolve_reasoning_model(config, reasoning_provider)
1203
+ openai_runtime: Optional[Dict[str, Any]] = None
1204
+ openrouter_runtime: Optional[Dict[str, Any]] = None
1205
+ if reasoning_provider == "openai":
1206
+ openai_runtime = _resolve_openai_runtime(config)
1207
+ if reasoning_provider == "openrouter":
1208
+ openrouter_runtime = _resolve_openrouter_runtime(config)
1209
+ log.info("Reasoning provider selected: %s (model: %s)", reasoning_provider, reasoning_model)
1210
+
1211
+ # Validate provider runtime requirements (except in dry-run).
1212
+ if not args.dry_run:
1213
+ if reasoning_provider == "anthropic":
1214
+ if not _HAS_ANTHROPIC:
1215
+ log.error(
1216
+ "Reasoning provider is 'anthropic' but the 'anthropic' package is missing.\n"
1217
+ "Install it:\n"
1218
+ " pip install anthropic\n"
1219
+ "Or: pip install -r requirements.txt"
1220
+ )
1221
+ return 1
1222
+ if not os.environ.get("ANTHROPIC_API_KEY", "").strip():
1223
+ log.error(
1224
+ "Reasoning provider is 'anthropic' but ANTHROPIC_API_KEY is not set.\n"
1225
+ " export ANTHROPIC_API_KEY='your-key-here'"
1226
+ )
1227
+ return 1
1228
+ elif reasoning_provider == "openrouter":
1229
+ assert openrouter_runtime is not None
1230
+ if not openrouter_runtime.get("api_key"):
1231
+ log.error(
1232
+ "Reasoning provider is 'openrouter' but OPENROUTER_API_KEY is not set.\n"
1233
+ " export OPENROUTER_API_KEY='your-key-here'"
1234
+ )
1235
+ return 1
1236
+ elif reasoning_provider == "openai":
1237
+ assert openai_runtime is not None
1238
+ if not openai_runtime.get("api_key"):
1239
+ log.error(
1240
+ "Reasoning provider is 'openai' but OPENAI_API_KEY is not set.\n"
1241
+ " export OPENAI_API_KEY='your-key-here'"
1242
+ )
1243
+ return 1
1244
+ else:
1245
+ log.error(
1246
+ "Unsupported reasoning provider '%s'. Supported: anthropic, openai, openrouter",
1247
+ reasoning_provider,
1248
+ )
1249
+ return 1
1250
+
844
1251
  log.info("Loaded config: %s v%s", config.get("agent", {}).get("name", "?"), config.get("agent", {}).get("version", "?"))
845
1252
 
846
1253
  # Determine mode
847
1254
  mode = args.mode or config.get("cycle", {}).get("mode", "single")
848
1255
 
849
- # Initialize Anthropic client
850
- if _HAS_ANTHROPIC and os.environ.get("ANTHROPIC_API_KEY"):
851
- client = anthropic.Anthropic()
852
- log.info("Anthropic client initialized (model: %s)", config.get("reasoning", {}).get("model", "?"))
1256
+ # Initialize provider client.
1257
+ client: Any = None
1258
+ if reasoning_provider == "anthropic" and _HAS_ANTHROPIC and os.environ.get("ANTHROPIC_API_KEY", "").strip():
1259
+ client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", "").strip())
1260
+ log.info("Anthropic client initialized (model: %s)", reasoning_model)
1261
+ elif reasoning_provider == "openai" and openai_runtime and openai_runtime.get("api_key"):
1262
+ client = openai_runtime
1263
+ log.info(
1264
+ "OpenAI client initialized (model: %s, base_url: %s)",
1265
+ reasoning_model,
1266
+ openai_runtime.get("base_url"),
1267
+ )
1268
+ elif reasoning_provider == "openrouter" and openrouter_runtime and openrouter_runtime.get("api_key"):
1269
+ client = openrouter_runtime
1270
+ log.info(
1271
+ "OpenRouter client initialized (model: %s, base_url: %s)",
1272
+ reasoning_model,
1273
+ openrouter_runtime.get("base_url"),
1274
+ )
853
1275
  else:
854
- client = None
855
- log.info("No Anthropic client (dry-run or missing SDK/key)")
1276
+ log.info(
1277
+ "No provider client initialized (provider=%s, likely dry-run or missing credentials)",
1278
+ reasoning_provider,
1279
+ )
856
1280
 
857
1281
  # Ensure memory directory exists
858
1282
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
@@ -873,7 +1297,14 @@ def main() -> int:
873
1297
  log.info("Running single cycle (#%d)...", cycle_number)
874
1298
  # Check PR feedback before planning
875
1299
  check_pr_feedback(config, cycle_number)
876
- results = run_cycle(config, client, cycle_number, dry_run=args.dry_run)
1300
+ results = run_cycle(
1301
+ config=config,
1302
+ client=client,
1303
+ cycle_number=cycle_number,
1304
+ reasoning_provider=reasoning_provider,
1305
+ reasoning_model=reasoning_model,
1306
+ dry_run=args.dry_run,
1307
+ )
877
1308
  succeeded = sum(1 for r in results if r.success)
878
1309
  failed = sum(1 for r in results if not r.success)
879
1310
  log.info("Cycle %d complete: %d succeeded, %d failed", cycle_number, succeeded, failed)
@@ -888,7 +1319,14 @@ def main() -> int:
888
1319
  cycles_run = 0
889
1320
  while True:
890
1321
  check_pr_feedback(config, cycle_number)
891
- results = run_cycle(config, client, cycle_number, dry_run=args.dry_run)
1322
+ results = run_cycle(
1323
+ config=config,
1324
+ client=client,
1325
+ cycle_number=cycle_number,
1326
+ reasoning_provider=reasoning_provider,
1327
+ reasoning_model=reasoning_model,
1328
+ dry_run=args.dry_run,
1329
+ )
892
1330
  succeeded = sum(1 for r in results if r.success)
893
1331
  failed = sum(1 for r in results if not r.success)
894
1332
  log.info("Cycle %d complete: %d succeeded, %d failed", cycle_number, succeeded, failed)