diff --git a/.env.example b/.env.example index 1328b838..c8adda53 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,4 @@ OPENAI_API_KEY= GOOGLE_API_KEY= ANTHROPIC_API_KEY= XAI_API_KEY= -OPENROUTER_API_KEY= +OPENROUTER_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9a2904a9..85dc2372 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,6 @@ __marimo__/ # Cache **/data_cache/ + +# Research Results +results/* \ No newline at end of file diff --git a/cli/main.py b/cli/main.py index 29294d8d..dc8ad691 100644 --- a/cli/main.py +++ b/cli/main.py @@ -547,15 +547,26 @@ def get_user_selections(): ) selected_research_depth = select_research_depth() - # Step 6: LLM Provider + # Step 5: LLM Provider console.print( create_question_box( - "Step 6: LLM Provider", "Select your LLM provider" + "Step 5: LLM Provider", "Select which service to talk to" ) ) selected_llm_provider, backend_url = select_llm_provider() - # Step 7: Thinking agents + # GitHub Copilot: run OAuth before proceeding + if selected_llm_provider.lower() == "copilot": + console.print( + create_question_box( + "Step 5b: Copilot Auth", + "Authenticate with the GitHub CLI to use GitHub Copilot", + ) + ) + if not perform_copilot_oauth(): + exit(1) + + # Step 6: Thinking agents console.print( create_question_box( "Step 7: Thinking Agents", "Select your thinking agents for analysis" diff --git a/cli/utils.py b/cli/utils.py index 62b50c9c..22aa0ac4 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -1,3 +1,4 @@ +import subprocess import questionary from typing import List, Optional, Tuple, Dict @@ -134,13 +135,76 @@ def select_research_depth() -> int: return choice +def _resolve_model_options( + provider: str, static_options: dict +) -> list[tuple[str, str]]: + """Return model options for the given provider. + + For Copilot, fetches the live model list from the API. + For all other providers, looks up the static options dict. + Exits with an error message if no models are available. + """ + if provider.lower() == "copilot": + options = fetch_copilot_models() + if not options: + console.print("[red]No Copilot models available. Exiting...[/red]") + exit(1) + else: + options = static_options.get(provider.lower()) + if not options: + console.print(f"[red]No models available for provider '{provider}'. Exiting...[/red]") + exit(1) + return options + + def select_shallow_thinking_agent(provider) -> str: """Select shallow thinking llm engine using an interactive selection.""" + # Define shallow thinking llm engine options with their corresponding model names + # Ordering: medium → light → heavy (balanced first for quick tasks) + # Within same tier, newer models first + SHALLOW_AGENT_OPTIONS = { + "openai": [ + ("GPT-5 Mini - Balanced speed, cost, and capability", "gpt-5-mini"), + ("GPT-5 Nano - High-throughput, simple tasks", "gpt-5-nano"), + ("GPT-5.4 - Latest frontier, 1M context", "gpt-5.4"), + ("GPT-4.1 - Smartest non-reasoning model", "gpt-4.1"), + ], + "anthropic": [ + ("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"), + ("Claude Haiku 4.5 - Fast, near-instant responses", "claude-haiku-4-5"), + ("Claude Sonnet 4.5 - Agents and coding", "claude-sonnet-4-5"), + ], + "google": [ + ("Gemini 3 Flash - Next-gen fast", "gemini-3-flash-preview"), + ("Gemini 2.5 Flash - Balanced, stable", "gemini-2.5-flash"), + ("Gemini 3.1 Flash Lite - Most cost-efficient", "gemini-3.1-flash-lite-preview"), + ("Gemini 2.5 Flash Lite - Fast, low-cost", "gemini-2.5-flash-lite"), + ], + "xai": [ + ("Grok 4.1 Fast (Non-Reasoning) - Speed optimized, 2M ctx", "grok-4-1-fast-non-reasoning"), + ("Grok 4 Fast (Non-Reasoning) - Speed optimized", "grok-4-fast-non-reasoning"), + ("Grok 4.1 Fast (Reasoning) - High-performance, 2M ctx", "grok-4-1-fast-reasoning"), + ], + "openrouter": [ + ("NVIDIA Nemotron 3 Nano 30B (free)", "nvidia/nemotron-3-nano-30b-a3b:free"), + ("Z.AI GLM 4.5 Air (free)", "z-ai/glm-4.5-air:free"), + ], + "ollama": [ + ("Qwen3:latest (8B, local)", "qwen3:latest"), + ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), + ("GLM-4.7-Flash:latest (30B, local)", "glm-4.7-flash:latest"), + ], + "copilot": [], + } + + options = _resolve_model_options(provider, SHALLOW_AGENT_OPTIONS) + choice = questionary.select( "Select Your [Quick-Thinking LLM Engine]:", choices=[ questionary.Choice(display, value=value) + for display, value in options for display, value in get_model_options(provider, "quick") ], instruction="\n- Use arrow keys to navigate\n- Press Enter to select", @@ -165,11 +229,53 @@ def select_shallow_thinking_agent(provider) -> str: def select_deep_thinking_agent(provider) -> str: """Select deep thinking llm engine using an interactive selection.""" + # Define deep thinking llm engine options with their corresponding model names + # Ordering: heavy → medium → light (most capable first for deep tasks) + # Within same tier, newer models first + DEEP_AGENT_OPTIONS = { + "openai": [ + ("GPT-5.4 - Latest frontier, 1M context", "gpt-5.4"), + ("GPT-5.2 - Strong reasoning, cost-effective", "gpt-5.2"), + ("GPT-5 Mini - Balanced speed, cost, and capability", "gpt-5-mini"), + ("GPT-5.4 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.4-pro"), + ], + "anthropic": [ + ("Claude Opus 4.6 - Most intelligent, agents and coding", "claude-opus-4-6"), + ("Claude Opus 4.5 - Premium, max intelligence", "claude-opus-4-5"), + ("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"), + ("Claude Sonnet 4.5 - Agents and coding", "claude-sonnet-4-5"), + ], + "google": [ + ("Gemini 3.1 Pro - Reasoning-first, complex workflows", "gemini-3.1-pro-preview"), + ("Gemini 3 Flash - Next-gen fast", "gemini-3-flash-preview"), + ("Gemini 2.5 Pro - Stable pro model", "gemini-2.5-pro"), + ("Gemini 2.5 Flash - Balanced, stable", "gemini-2.5-flash"), + ], + "xai": [ + ("Grok 4 - Flagship model", "grok-4-0709"), + ("Grok 4.1 Fast (Reasoning) - High-performance, 2M ctx", "grok-4-1-fast-reasoning"), + ("Grok 4 Fast (Reasoning) - High-performance", "grok-4-fast-reasoning"), + ("Grok 4.1 Fast (Non-Reasoning) - Speed optimized, 2M ctx", "grok-4-1-fast-non-reasoning"), + ], + "openrouter": [ + ("Z.AI GLM 4.5 Air (free)", "z-ai/glm-4.5-air:free"), + ("NVIDIA Nemotron 3 Nano 30B (free)", "nvidia/nemotron-3-nano-30b-a3b:free"), + ], + "ollama": [ + ("GLM-4.7-Flash:latest (30B, local)", "glm-4.7-flash:latest"), + ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), + ("Qwen3:latest (8B, local)", "qwen3:latest"), + ], + "copilot": [], # populated dynamically by fetch_copilot_models() + } + + options = _resolve_model_options(provider, DEEP_AGENT_OPTIONS) + choice = questionary.select( "Select Your [Deep-Thinking LLM Engine]:", choices=[ questionary.Choice(display, value=value) - for display, value in get_model_options(provider, "deep") + for display, value in options ], instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style( @@ -187,9 +293,23 @@ def select_deep_thinking_agent(provider) -> str: return choice +def fetch_copilot_models() -> list[tuple[str, str]]: + """Fetch models from the GitHub Copilot inference API. + + Returns a list of (display_label, model_id) tuples sorted by model ID. + Requires authentication via ``gh auth login`` with a Copilot subscription. + """ + from tradingagents.llm_clients.copilot_client import list_copilot_models + + console.print("[dim]Fetching available Copilot models...[/dim]") + models = list_copilot_models() + if not models: + console.print("[yellow]Warning: Could not fetch Copilot models.[/yellow]") + return models + + def select_llm_provider() -> tuple[str, str]: - """Select the OpenAI api url using interactive selection.""" - # Define OpenAI api options with their corresponding endpoints + """Select the LLM provider using interactive selection.""" BASE_URLS = [ ("OpenAI", "https://api.openai.com/v1"), ("Google", "https://generativelanguage.googleapis.com/v1"), @@ -197,8 +317,9 @@ def select_llm_provider() -> tuple[str, str]: ("xAI", "https://api.x.ai/v1"), ("Openrouter", "https://openrouter.ai/api/v1"), ("Ollama", "http://localhost:11434/v1"), + ("Copilot", ""), # resolved at runtime via GraphQL ] - + choice = questionary.select( "Select your LLM Provider:", choices=[ @@ -214,17 +335,64 @@ def select_llm_provider() -> tuple[str, str]: ] ), ).ask() - + if choice is None: - console.print("\n[red]no OpenAI backend selected. Exiting...[/red]") + console.print("\n[red]No LLM provider selected. Exiting...[/red]") exit(1) - + display_name, url = choice print(f"You selected: {display_name}\tURL: {url}") return display_name, url +def perform_copilot_oauth() -> bool: + """Ensure the user is authenticated with the GitHub CLI for Copilot. + + Checks for an existing token and verifies Copilot access. If the token + is missing, offers to run ``gh auth login`` interactively. + + Returns True if a valid token with Copilot access is available, False otherwise. + """ + from tradingagents.llm_clients.copilot_client import check_copilot_auth, get_github_token + + token = get_github_token() + if token: + if check_copilot_auth(): + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") + return True + console.print( + "[yellow]⚠ GitHub token found but Copilot access failed. " + "Check your Copilot subscription.[/yellow]" + ) + return False + + console.print( + "[yellow]⚠ No GitHub token found.[/yellow] " + "You need to authenticate to use GitHub Copilot." + ) + should_login = questionary.confirm("Run `gh auth login` now?", default=True).ask() + if not should_login: + console.print("[red]GitHub authentication skipped. Exiting...[/red]") + return False + + try: + result = subprocess.run(["gh", "auth", "login"]) + if result.returncode != 0: + console.print("[red]`gh auth login` failed.[/red]") + return False + except FileNotFoundError: + console.print("[red]Error: `gh` command not found. Please install the GitHub CLI.[/red]") + return False + + if check_copilot_auth(): + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") + return True + + console.print("[red]Could not retrieve token or verify Copilot access after login.[/red]") + return False + + def ask_openai_reasoning_effort() -> str: """Ask for OpenAI reasoning effort level.""" choices = [ diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py new file mode 100644 index 00000000..e1867d8b --- /dev/null +++ b/tradingagents/llm_clients/copilot_client.py @@ -0,0 +1,200 @@ +"""GitHub Copilot LLM client. + +Authenticates via the ``gh`` CLI (``gh auth token``) and calls the Copilot +inference API (api.individual.githubcopilot.com) using headers reverse- +engineered from the Copilot CLI (copilot-developer-cli integration ID). + +No env var or separate auth module needed — run ``gh auth login`` once. +""" + +import subprocess +from copy import deepcopy +from typing import Any, Optional + +import requests +from langchain_openai import ChatOpenAI + +from .base_client import BaseLLMClient, normalize_content +from .validators import validate_model + +# Required headers for the Copilot inference API (reverse-engineered from +# /usr/local/lib/node_modules/@github/copilot). +_COPILOT_HEADERS = { + "Copilot-Integration-Id": "copilot-developer-cli", + "X-GitHub-Api-Version": "2025-05-01", + "Openai-Intent": "conversation-agent", +} + +# Models that only support /responses, not /chat/completions on the Copilot endpoint. +_RESPONSES_ONLY_MODELS = frozenset(( + "gpt-5.4", "gpt-5.4-mini", + "gpt-5.3-codex", "gpt-5.2-codex", + "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max", +)) + +_PASSTHROUGH_KWARGS = ( + "timeout", "max_retries", "reasoning_effort", + "callbacks", "http_client", "http_async_client", +) + + +def get_github_token() -> Optional[str]: + """Return a GitHub token via the ``gh`` CLI.""" + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + +def _get_copilot_api_url() -> str: + """Resolve the Copilot inference base URL via GraphQL, falling back to the + standard individual endpoint.""" + token = get_github_token() + if token: + try: + resp = requests.post( + "https://api.github.com/graphql", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={"query": "{ viewer { copilotEndpoints { api } } }"}, + timeout=5, + ) + if resp.status_code == 200: + api = resp.json()["data"]["viewer"]["copilotEndpoints"]["api"] + if api: + return api.rstrip("/") + except requests.exceptions.RequestException: + pass + return "https://api.individual.githubcopilot.com" + + +def list_copilot_models() -> list[tuple[str, str]]: + """Fetch available Copilot models from the inference API. + + Returns a list of ``(display_label, model_id)`` tuples sorted by model ID. + Requires ``gh auth login`` with an active Copilot subscription. + """ + token = get_github_token() + if not token: + return [] + try: + url = _get_copilot_api_url() + resp = requests.get( + f"{url}/models", + headers={"Authorization": f"Bearer {token}", **_COPILOT_HEADERS}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + models = data.get("data", data) if isinstance(data, dict) else data + chat_models = [m for m in models if not m.get("id", "").startswith("text-embedding")] + return [(m["id"], m["id"]) for m in sorted(chat_models, key=lambda x: x.get("id", ""))] + except requests.exceptions.RequestException: + return [] + + +def check_copilot_auth() -> bool: + """Return True if a GitHub token with Copilot access is available.""" + token = get_github_token() + if not token: + return False + try: + url = _get_copilot_api_url() + resp = requests.get( + f"{url}/models", + headers={"Authorization": f"Bearer {token}", **_COPILOT_HEADERS}, + timeout=5, + ) + return resp.status_code == 200 + except requests.exceptions.RequestException: + return False # Network error should be treated as an auth failure + + +class NormalizedChatOpenAI(ChatOpenAI): + """ChatOpenAI with normalized content output.""" + + def _create_chat_result(self, response, generation_info=None): + return super()._create_chat_result( + _sanitize_copilot_response(response), generation_info + ) + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + +def _sanitize_copilot_response(response: Any) -> Any: + """Normalize Copilot token usage fields for langchain_openai. + + Copilot can return ``service_tier`` along with ``None`` values in + ``cached_tokens`` or ``reasoning_tokens``. ``langchain_openai`` subtracts + those fields from the prompt/completion totals, which raises ``TypeError`` + when the detail value is ``None``. + """ + if isinstance(response, dict): + response_dict = deepcopy(response) + elif hasattr(response, "model_dump"): + response_dict = response.model_dump() + else: + return response + + usage = response_dict.get("usage") + if not isinstance(usage, dict): + return response_dict + + if response_dict.get("service_tier") not in {"priority", "flex"}: + return response_dict + + prompt_details = usage.get("prompt_tokens_details") + if isinstance(prompt_details, dict) and prompt_details.get("cached_tokens") is None: + prompt_details["cached_tokens"] = 0 + + completion_details = usage.get("completion_tokens_details") + if ( + isinstance(completion_details, dict) + and completion_details.get("reasoning_tokens") is None + ): + completion_details["reasoning_tokens"] = 0 + + return response_dict + + +class CopilotClient(BaseLLMClient): + """Client for GitHub Copilot inference API. + + Uses the gh CLI for authentication. Automatically routes models that only + support the Responses API (gpt-5.4, codex variants) to ``/responses`` + instead of ``/chat/completions``. + """ + + def get_llm(self) -> Any: + """Return configured ChatOpenAI instance pointed at the Copilot API.""" + token = get_github_token() + if not token: + raise RuntimeError( + "No GitHub token found. Run `gh auth login` to authenticate." + ) + copilot_url = _get_copilot_api_url() + + llm_kwargs = { + "model": self.model, + "base_url": copilot_url, + "api_key": token, + "default_headers": dict(_COPILOT_HEADERS), + } + + for key in _PASSTHROUGH_KWARGS: + if key in self.kwargs: + llm_kwargs[key] = self.kwargs[key] + + if self.model in _RESPONSES_ONLY_MODELS: + llm_kwargs["use_responses_api"] = True + + return NormalizedChatOpenAI(**llm_kwargs) + + def validate_model(self) -> bool: + return validate_model("copilot", self.model) diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index 93c2a7d3..4a8ce6ad 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -2,6 +2,7 @@ from typing import Optional from .base_client import BaseLLMClient from .openai_client import OpenAIClient +from .copilot_client import CopilotClient from .anthropic_client import AnthropicClient from .google_client import GoogleClient @@ -15,7 +16,7 @@ def create_llm_client( """Create an LLM client for the specified provider. Args: - provider: LLM provider (openai, anthropic, google, xai, ollama, openrouter) + provider: LLM provider (openai, anthropic, google, xai, ollama, openrouter, copilot) model: Model name/identifier base_url: Optional base URL for API endpoint **kwargs: Additional provider-specific arguments @@ -34,6 +35,9 @@ def create_llm_client( """ provider_lower = provider.lower() + if provider_lower == "copilot": + return CopilotClient(model, base_url, **kwargs) + if provider_lower in ("openai", "ollama", "openrouter"): return OpenAIClient(model, base_url, provider=provider_lower, **kwargs) diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 4f2e1b32..dd4d791a 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -83,4 +83,4 @@ class OpenAIClient(BaseLLMClient): def validate_model(self) -> bool: """Validate model for the provider.""" - return validate_model(self.provider, self.model) + return validate_model(self.provider, self.model) \ No newline at end of file diff --git a/tradingagents/llm_clients/validators.py b/tradingagents/llm_clients/validators.py index 4e6d457b..da0c6276 100644 --- a/tradingagents/llm_clients/validators.py +++ b/tradingagents/llm_clients/validators.py @@ -4,20 +4,63 @@ from .model_catalog import get_known_models VALID_MODELS = { - provider: models - for provider, models in get_known_models().items() - if provider not in ("ollama", "openrouter") + "openai": [ + # GPT-5 series + "gpt-5.4-pro", + "gpt-5.4", + "gpt-5.2", + "gpt-5.1", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + # GPT-4.1 series + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + ], + "anthropic": [ + # Claude 4.6 series (latest) + "claude-opus-4-6", + "claude-sonnet-4-6", + # Claude 4.5 series + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", + ], + "google": [ + # Gemini 3.1 series (preview) + "gemini-3.1-pro-preview", + "gemini-3.1-flash-lite-preview", + # Gemini 3 series (preview) + "gemini-3-flash-preview", + # Gemini 2.5 series + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ], + "xai": [ + # Grok 4.1 series + "grok-4-1-fast-reasoning", + "grok-4-1-fast-non-reasoning", + # Grok 4 series + "grok-4-0709", + "grok-4-fast-reasoning", + "grok-4-fast-non-reasoning", + ], + # GitHub Copilot: model availability depends on plan and may change. + # Accept any model ID and let the API validate it. + "copilot": [], } def validate_model(provider: str, model: str) -> bool: """Check if model name is valid for the given provider. - For ollama, openrouter - any model is accepted. + For ollama, openrouter, copilot - any model is accepted. """ provider_lower = provider.lower() - if provider_lower in ("ollama", "openrouter"): + if provider_lower in ("ollama", "openrouter", "copilot"): return True if provider_lower not in VALID_MODELS: