@gaia-minds/assistant-cli 0.1.0 → 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,13 +119,20 @@ 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
124
130
  # ---------------------------------------------------------------------------
125
131
 
126
132
  CONFIG_PATH = SCRIPT_DIR / "agent-config.yml"
127
- MEMORY_DIR = SCRIPT_DIR / "agent-memory"
133
+ MEMORY_DIR = Path(
134
+ os.environ.get("GAIA_AGENT_MEMORY_DIR", str(SCRIPT_DIR / "agent-memory"))
135
+ ).expanduser()
128
136
  DECISIONS_PATH = MEMORY_DIR / "decisions.jsonl"
129
137
  LESSONS_PATH = MEMORY_DIR / "lessons.jsonl"
130
138
  STATE_PATH = MEMORY_DIR / "state.json"
@@ -146,6 +154,181 @@ def load_config(path: Path = CONFIG_PATH) -> Dict[str, Any]:
146
154
  return config
147
155
 
148
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
+
149
332
  # ---------------------------------------------------------------------------
150
333
  # Memory
151
334
  # ---------------------------------------------------------------------------
@@ -388,7 +571,7 @@ def action_allowed_in_track(action_type: str, active_track: str, config: Dict[st
388
571
 
389
572
 
390
573
  # ---------------------------------------------------------------------------
391
- # Claude reasoning
574
+ # Reasoning
392
575
  # ---------------------------------------------------------------------------
393
576
 
394
577
  SYSTEM_PROMPT_TEMPLATE = """\
@@ -480,19 +663,168 @@ def state_to_summary(state: RepoState) -> Dict[str, Any]:
480
663
  }
481
664
 
482
665
 
483
- 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(
484
804
  client: Any,
485
805
  config: Dict[str, Any],
486
806
  state: RepoState,
487
807
  memory: Dict[str, Any],
488
808
  constitution: str,
489
809
  active_track: str,
810
+ provider: str,
811
+ model: str,
490
812
  ) -> Dict[str, Any]:
491
- """Ask Claude to analyze state and propose actions."""
813
+ """Ask the configured reasoning provider to analyze state and propose actions."""
492
814
  reasoning_config = config.get("reasoning", {})
493
- model = reasoning_config.get("model", "claude-sonnet-4-5-20250929")
815
+ reasoning_config = reasoning_config if isinstance(reasoning_config, dict) else {}
494
816
  max_tokens = reasoning_config.get("max_tokens", 4096)
495
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
496
828
 
497
829
  system_prompt = SYSTEM_PROMPT_TEMPLATE.format(constitution=constitution)
498
830
 
@@ -511,42 +843,62 @@ def ask_claude_for_plan(
511
843
  budget_policy_json=json.dumps(budget_policy, indent=2),
512
844
  )
513
845
 
514
- log.info("Asking Claude (%s) for a plan...", model)
846
+ log.info("Asking reasoning provider '%s' (%s) for a plan...", provider, model)
515
847
  log.debug("System prompt: %d chars, User prompt: %d chars", len(system_prompt), len(user_prompt))
516
848
 
517
- response = client.messages.create(
518
- model=model,
519
- max_tokens=max_tokens,
520
- temperature=temperature,
521
- system=system_prompt,
522
- messages=[{"role": "user", "content": user_prompt}],
523
- )
524
-
525
- # Extract text from response
526
- text = ""
527
- for block in response.content:
528
- if hasattr(block, "text"):
529
- 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": []}
530
887
 
531
- # Parse JSON from response
532
- # Handle case where Claude wraps in code fences
533
- text = text.strip()
534
- if text.startswith("```"):
535
- # Remove code fences
536
- lines = text.splitlines()
537
- lines = [l for l in lines if not l.strip().startswith("```")]
538
- text = "\n".join(lines)
888
+ # Parse JSON from response text.
889
+ text = _strip_markdown_fences(text)
539
890
 
540
891
  try:
541
892
  plan = json.loads(text)
542
893
  except json.JSONDecodeError as exc:
543
- log.error("Failed to parse Claude's response as JSON: %s", exc)
894
+ log.error("Failed to parse reasoning response as JSON: %s", exc)
544
895
  log.error("Raw response: %s", text[:500])
545
896
  plan = {"reasoning": f"Failed to parse response: {exc}", "actions": []}
546
897
 
547
898
  actions = plan.get("actions", [])
548
899
  log.info(
549
- "Claude proposed %d action(s): %s",
900
+ "Reasoning provider '%s' proposed %d action(s): %s",
901
+ provider,
550
902
  len(actions),
551
903
  ", ".join(a.get("type", "?") for a in actions) or "(none)",
552
904
  )
@@ -564,6 +916,8 @@ def run_cycle(
564
916
  config: Dict[str, Any],
565
917
  client: Any,
566
918
  cycle_number: int,
919
+ reasoning_provider: str,
920
+ reasoning_model: str,
567
921
  dry_run: bool = False,
568
922
  ) -> List[ActionResult]:
569
923
  """Run one complete agent cycle."""
@@ -588,12 +942,30 @@ def run_cycle(
588
942
  # 3. Load constitution
589
943
  constitution = load_constitution(repo_root)
590
944
 
591
- # 4. Ask Claude for a plan
945
+ # 4. Ask reasoning provider for a plan
592
946
  if client is None:
593
- log.info("No Anthropic client available -- skipping Claude reasoning (dry-run)")
594
- 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
+ }
595
958
  else:
596
- 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
+ )
597
969
 
598
970
  actions = plan.get("actions", [])
599
971
  if not actions:
@@ -630,8 +1002,8 @@ def run_cycle(
630
1002
  action,
631
1003
  constitution,
632
1004
  json.dumps(memory.get("recent_decisions", [])[-5:]),
633
- client=client if _HAS_ANTHROPIC else None,
634
- 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,
635
1007
  )
636
1008
 
637
1009
  log.info(
@@ -815,23 +1187,6 @@ def main() -> int:
815
1187
  level = logging.DEBUG if args.verbose else logging.INFO
816
1188
  logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, stream=sys.stderr)
817
1189
 
818
- # Check for anthropic SDK (not needed in dry-run)
819
- if not _HAS_ANTHROPIC and not args.dry_run:
820
- log.error(
821
- "The 'anthropic' package is required. Install it:\n"
822
- " pip install anthropic\n"
823
- "Or: pip install -r requirements.txt"
824
- )
825
- return 1
826
-
827
- # Check for API key (not needed in dry-run)
828
- if not os.environ.get("ANTHROPIC_API_KEY") and not args.dry_run:
829
- log.error(
830
- "ANTHROPIC_API_KEY environment variable is not set.\n"
831
- " export ANTHROPIC_API_KEY='your-key-here'"
832
- )
833
- return 1
834
-
835
1190
  # Load config
836
1191
  config_path = Path(args.config)
837
1192
  if not config_path.exists():
@@ -839,18 +1194,89 @@ def main() -> int:
839
1194
  return 1
840
1195
 
841
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
+
842
1251
  log.info("Loaded config: %s v%s", config.get("agent", {}).get("name", "?"), config.get("agent", {}).get("version", "?"))
843
1252
 
844
1253
  # Determine mode
845
1254
  mode = args.mode or config.get("cycle", {}).get("mode", "single")
846
1255
 
847
- # Initialize Anthropic client
848
- if _HAS_ANTHROPIC and os.environ.get("ANTHROPIC_API_KEY"):
849
- client = anthropic.Anthropic()
850
- 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
+ )
851
1275
  else:
852
- client = None
853
- 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
+ )
854
1280
 
855
1281
  # Ensure memory directory exists
856
1282
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
@@ -871,7 +1297,14 @@ def main() -> int:
871
1297
  log.info("Running single cycle (#%d)...", cycle_number)
872
1298
  # Check PR feedback before planning
873
1299
  check_pr_feedback(config, cycle_number)
874
- 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
+ )
875
1308
  succeeded = sum(1 for r in results if r.success)
876
1309
  failed = sum(1 for r in results if not r.success)
877
1310
  log.info("Cycle %d complete: %d succeeded, %d failed", cycle_number, succeeded, failed)
@@ -886,7 +1319,14 @@ def main() -> int:
886
1319
  cycles_run = 0
887
1320
  while True:
888
1321
  check_pr_feedback(config, cycle_number)
889
- 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
+ )
890
1330
  succeeded = sum(1 for r in results if r.success)
891
1331
  failed = sum(1 for r in results if not r.success)
892
1332
  log.info("Cycle %d complete: %d succeeded, %d failed", cycle_number, succeeded, failed)