From d8ac2122536c7ac3dd791b2dd89dc45f46ede0ff Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 13:18:55 +0000 Subject: [PATCH 01/20] feat: add GitHub Copilot provider with OAuth auth via gh CLI Replace GitHub Models (models.github.ai) with GitHub Copilot inference API (api.individual.githubcopilot.com). Auth uses gh CLI token with required Copilot headers (Copilot-Integration-Id, X-GitHub-Api-Version). - Add tradingagents/auth/ module: gh token retrieval, Copilot URL resolution via GraphQL, Codex OAuth token with auto-refresh - Add "copilot" provider to OpenAIClient with dynamic base URL and headers - Add live model listing from Copilot /models endpoint (27 models) - Add perform_copilot_oauth() with Copilot access verification - Remove all GitHub Models (models.github.ai) references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 4 + cli/main.py | 25 +++- cli/utils.py | 152 +++++++++++++++++++-- tradingagents/auth/__init__.py | 4 + tradingagents/auth/codex_token.py | 109 +++++++++++++++ tradingagents/auth/github_token.py | 68 +++++++++ tradingagents/default_config.py | 3 + tradingagents/llm_clients/factory.py | 4 +- tradingagents/llm_clients/openai_client.py | 31 ++++- tradingagents/llm_clients/validators.py | 7 +- 10 files changed, 379 insertions(+), 28 deletions(-) create mode 100644 tradingagents/auth/__init__.py create mode 100644 tradingagents/auth/codex_token.py create mode 100644 tradingagents/auth/github_token.py diff --git a/.env.example b/.env.example index 1328b838..d8de7ad5 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ GOOGLE_API_KEY= ANTHROPIC_API_KEY= XAI_API_KEY= OPENROUTER_API_KEY= + +# GitHub Copilot authenticates via the GitHub CLI (`gh auth login`). +# No API key needed — the token from `gh auth token` is used automatically. +# Requires an active Copilot subscription (Pro/Pro+). diff --git a/cli/main.py b/cli/main.py index 53837db2..c0048bfb 100644 --- a/cli/main.py +++ b/cli/main.py @@ -538,13 +538,26 @@ def get_user_selections(): ) selected_research_depth = select_research_depth() - # Step 5: OpenAI backend + # Step 5: LLM Provider console.print( create_question_box( - "Step 5: OpenAI backend", "Select which service to talk to" + "Step 5: LLM Provider", "Select which service to talk to" ) ) selected_llm_provider, backend_url = select_llm_provider() + + provider_id = selected_llm_provider.lower() + + # GitHub Copilot: run OAuth before proceeding + if provider_id == "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( @@ -552,15 +565,15 @@ def get_user_selections(): "Step 6: Thinking Agents", "Select your thinking agents for analysis" ) ) - selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) - selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider) + selected_shallow_thinker = select_shallow_thinking_agent(provider_id) + selected_deep_thinker = select_deep_thinking_agent(provider_id) # Step 7: Provider-specific thinking configuration thinking_level = None reasoning_effort = None anthropic_effort = None - provider_lower = selected_llm_provider.lower() + provider_lower = provider_id if provider_lower == "google": console.print( create_question_box( @@ -591,7 +604,7 @@ def get_user_selections(): "analysis_date": analysis_date, "analysts": selected_analysts, "research_depth": selected_research_depth, - "llm_provider": selected_llm_provider.lower(), + "llm_provider": provider_id, "backend_url": backend_url, "shallow_thinker": selected_shallow_thinker, "deep_thinker": selected_deep_thinker, diff --git a/cli/utils.py b/cli/utils.py index 18abc3a7..c48711df 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -1,3 +1,4 @@ +import subprocess import questionary from typing import List, Optional, Tuple, Dict @@ -136,9 +137,7 @@ def select_research_depth() -> int: 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"), @@ -171,13 +170,25 @@ def select_shallow_thinking_agent(provider) -> str: ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), ("GLM-4.7-Flash:latest (30B, local)", "glm-4.7-flash:latest"), ], + "copilot": [], # populated dynamically by fetch_copilot_models() } + if provider.lower() == "copilot": + options = fetch_copilot_models() + if not options: + console.print("[red]No Copilot models available. Exiting...[/red]") + exit(1) + else: + options = SHALLOW_AGENT_OPTIONS.get(provider.lower()) + if not options: + console.print(f"[red]No models available for provider '{provider}'. Exiting...[/red]") + exit(1) + choice = questionary.select( "Select Your [Quick-Thinking LLM Engine]:", choices=[ questionary.Choice(display, value=value) - for display, value in SHALLOW_AGENT_OPTIONS[provider.lower()] + for display, value in options ], instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style( @@ -201,9 +212,7 @@ 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"), @@ -238,13 +247,25 @@ def select_deep_thinking_agent(provider) -> str: ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), ("Qwen3:latest (8B, local)", "qwen3:latest"), ], + "copilot": [], # populated dynamically by fetch_copilot_models() } + if provider.lower() == "copilot": + options = fetch_copilot_models() + if not options: + console.print("[red]No Copilot models available. Exiting...[/red]") + exit(1) + else: + options = DEEP_AGENT_OPTIONS.get(provider.lower()) + if not options: + console.print(f"[red]No models available for provider '{provider}'. Exiting...[/red]") + exit(1) + choice = questionary.select( "Select Your [Deep-Thinking LLM Engine]:", choices=[ questionary.Choice(display, value=value) - for display, value in DEEP_AGENT_OPTIONS[provider.lower()] + for display, value in options ], instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style( @@ -262,9 +283,53 @@ 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. + """ + import requests + from tradingagents.auth import get_github_token, COPILOT_HEADERS, get_copilot_api_url + + token = get_github_token() + if not token: + console.print("[red]No GitHub token available. Run `gh auth login` first.[/red]") + return [] + + try: + console.print("[dim]Fetching available Copilot models...[/dim]") + copilot_url = get_copilot_api_url() + headers = { + "Authorization": f"Bearer {token}", + **COPILOT_HEADERS, + } + resp = requests.get( + f"{copilot_url}/models", + headers=headers, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + + models = data.get("data", data) if isinstance(data, dict) else data + # Filter to chat-capable models (exclude embeddings) + 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 Exception as e: + console.print(f"[yellow]Warning: Could not fetch Copilot models: {e}[/yellow]") + return [] + + 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"), @@ -272,8 +337,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=[ @@ -289,17 +355,79 @@ 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.auth import get_github_token + + token = get_github_token() + if token: + # Verify Copilot access + import requests + from tradingagents.auth import COPILOT_HEADERS, get_copilot_api_url + try: + copilot_url = get_copilot_api_url() + resp = requests.get( + f"{copilot_url}/models", + headers={"Authorization": f"Bearer {token}", **COPILOT_HEADERS}, + timeout=5, + ) + if resp.status_code == 200: + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") + return True + else: + console.print( + f"[yellow]⚠ GitHub token found but Copilot access failed " + f"(HTTP {resp.status_code}). Check your Copilot subscription.[/yellow]" + ) + return False + except Exception: + # Network error — accept the token optimistically + console.print("[green]✓ Authenticated with GitHub CLI (Copilot access not verified)[/green]") + return True + + 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 + + result = subprocess.run(["gh", "auth", "login"]) + if result.returncode != 0: + console.print("[red]`gh auth login` failed.[/red]") + return False + + token = get_github_token() + if token: + console.print("[green]✓ GitHub authentication successful![/green]") + return True + + console.print("[red]Could not retrieve token after login.[/red]") + return False + + def ask_openai_reasoning_effort() -> str: """Ask for OpenAI reasoning effort level.""" choices = [ diff --git a/tradingagents/auth/__init__.py b/tradingagents/auth/__init__.py new file mode 100644 index 00000000..5ad89708 --- /dev/null +++ b/tradingagents/auth/__init__.py @@ -0,0 +1,4 @@ +from .codex_token import get_codex_token +from .github_token import get_github_token, get_copilot_api_url, COPILOT_HEADERS + +__all__ = ["get_codex_token", "get_github_token", "get_copilot_api_url", "COPILOT_HEADERS"] diff --git a/tradingagents/auth/codex_token.py b/tradingagents/auth/codex_token.py new file mode 100644 index 00000000..1039b7ba --- /dev/null +++ b/tradingagents/auth/codex_token.py @@ -0,0 +1,109 @@ +"""OpenAI Codex OAuth token reader with auto-refresh. + +Reads credentials stored by the OpenAI Codex CLI at ~/.codex/auth.json. +Checks expiry and refreshes automatically via the OpenAI token endpoint +before returning a valid access token — the same pattern OpenClaw uses +with its auth-profiles.json token sink. + +Token refresh invalidates the previous refresh token, so only one tool +should hold the Codex credentials at a time (same caveat as OpenClaw). +""" + +import json +import time +from pathlib import Path +from typing import Optional + +import requests + +_AUTH_FILE = Path.home() / ".codex" / "auth.json" +_TOKEN_URL = "https://auth.openai.com/oauth/token" +# Refresh this many seconds before actual expiry to avoid edge-case failures. +_EXPIRY_BUFFER_SECS = 60 + + +def _load_auth() -> Optional[dict]: + """Load the Codex auth file, return None if missing or malformed.""" + if not _AUTH_FILE.exists(): + return None + try: + return json.loads(_AUTH_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return None + + +def _save_auth(data: dict) -> None: + _AUTH_FILE.write_text(json.dumps(data, indent=2)) + + +def _is_expired(auth: dict) -> bool: + """Return True if the access token is expired (or close to expiring).""" + expires = auth.get("expires_at") or auth.get("tokens", {}).get("expires_at") + if expires is None: + # Fall back to decoding the JWT exp claim. + try: + import base64 + token = auth["tokens"]["access_token"] + payload = token.split(".")[1] + decoded = json.loads(base64.b64decode(payload + "==")) + expires = decoded.get("exp") + except Exception: + return False # Can't determine — assume valid. + return time.time() >= (expires - _EXPIRY_BUFFER_SECS) + + +def _refresh(auth: dict) -> dict: + """Exchange the refresh token for a new token pair and persist it.""" + refresh_token = auth["tokens"]["refresh_token"] + resp = requests.post( + _TOKEN_URL, + json={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + headers={"Content-Type": "application/json"}, + timeout=15, + ) + resp.raise_for_status() + new_tokens = resp.json() + + # Merge new tokens back into the auth structure and persist. + auth["tokens"].update({ + "access_token": new_tokens["access_token"], + "refresh_token": new_tokens.get("refresh_token", refresh_token), + "expires_at": new_tokens.get("expires_in") and + int(time.time()) + int(new_tokens["expires_in"]), + }) + auth["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + _save_auth(auth) + return auth + + +def get_codex_token() -> Optional[str]: + """Return a valid OpenAI access token from the Codex CLI auth file. + + Resolution order: + 1. OPENAI_API_KEY environment variable (explicit key always wins) + 2. ~/.codex/auth.json — auto-refreshes if the access token is expired + + Returns None if no credentials are found. + """ + import os + explicit = os.environ.get("OPENAI_API_KEY") + if explicit: + return explicit + + auth = _load_auth() + if not auth or "tokens" not in auth: + return None + + # Refresh if expired. + if _is_expired(auth): + try: + auth = _refresh(auth) + except Exception: + # Refresh failed — return whatever token we have and let the + # API call surface a clearer error. + pass + + return auth["tokens"].get("access_token") diff --git a/tradingagents/auth/github_token.py b/tradingagents/auth/github_token.py new file mode 100644 index 00000000..c2be73bc --- /dev/null +++ b/tradingagents/auth/github_token.py @@ -0,0 +1,68 @@ +"""GitHub token retrieval for the GitHub Copilot API. + +Uses the ``gh`` CLI exclusively — no explicit API token or env var. +Run ``gh auth login`` once to authenticate; this module handles the rest. +""" + +import subprocess +from typing import Optional + + +def get_github_token() -> Optional[str]: + """Return a GitHub token obtained via the GitHub CLI (``gh auth token``). + + Returns None if the CLI is unavailable or the user is not logged in. + """ + 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. + + Queries the GitHub GraphQL API for the user's Copilot endpoints. + Falls back to the standard individual endpoint on failure. + """ + import requests + + token = get_github_token() + if not token: + return "https://api.individual.githubcopilot.com" + + 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 Exception: + pass + + return "https://api.individual.githubcopilot.com" + + +# Required headers for the Copilot inference API (reverse-engineered from the +# Copilot CLI at /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", +} diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 898e1e1e..5c3c1d87 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -8,6 +8,9 @@ DEFAULT_CONFIG = { "dataflows/data_cache", ), # LLM settings + # Set llm_provider to "copilot" to use GitHub Copilot (no explicit API key + # needed — authenticates via `gh auth token` from the GitHub CLI). + # Available models are fetched dynamically from the Copilot inference API. "llm_provider": "openai", "deep_think_llm": "gpt-5.2", "quick_think_llm": "gpt-5-mini", diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index 93c2a7d3..cfa5e4ae 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -15,7 +15,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,7 +34,7 @@ def create_llm_client( """ provider_lower = provider.lower() - if provider_lower in ("openai", "ollama", "openrouter"): + if provider_lower in ("openai", "ollama", "openrouter", "copilot"): return OpenAIClient(model, base_url, provider=provider_lower, **kwargs) if provider_lower == "xai": diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index fd9b4e33..f08ea84f 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -5,6 +5,7 @@ from langchain_openai import ChatOpenAI from .base_client import BaseLLMClient, normalize_content from .validators import validate_model +from ..auth import get_codex_token, get_github_token, get_copilot_api_url, COPILOT_HEADERS class NormalizedChatOpenAI(ChatOpenAI): @@ -24,21 +25,25 @@ _PASSTHROUGH_KWARGS = ( "api_key", "callbacks", "http_client", "http_async_client", ) -# Provider base URLs and API key env vars +# Provider base URLs and API key env vars. +# Copilot: uses the GitHub Copilot inference API, authenticated via ``gh`` +# CLI token with Copilot-specific headers. No env var needed. _PROVIDER_CONFIG = { "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), "ollama": ("http://localhost:11434/v1", None), + "copilot": (None, None), # base_url resolved at runtime via GraphQL } class OpenAIClient(BaseLLMClient): - """Client for OpenAI, Ollama, OpenRouter, and xAI providers. + """Client for OpenAI, Ollama, OpenRouter, xAI, and GitHub Copilot providers. For native OpenAI models, uses the Responses API (/v1/responses) which supports reasoning_effort with function tools across all model families (GPT-4.1, GPT-5). Third-party compatible providers (xAI, OpenRouter, - Ollama) use standard Chat Completions. + Ollama) use standard Chat Completions. GitHub Copilot uses the Copilot + inference API with special headers. """ def __init__( @@ -56,9 +61,18 @@ class OpenAIClient(BaseLLMClient): llm_kwargs = {"model": self.model} # Provider-specific base URL and auth - if self.provider in _PROVIDER_CONFIG: + if self.provider == "copilot": + # GitHub Copilot: resolve base URL and inject required headers + copilot_url = get_copilot_api_url() + llm_kwargs["base_url"] = copilot_url + token = get_github_token() + if token: + llm_kwargs["api_key"] = token + llm_kwargs["default_headers"] = dict(COPILOT_HEADERS) + elif self.provider in _PROVIDER_CONFIG: base_url, api_key_env = _PROVIDER_CONFIG[self.provider] - llm_kwargs["base_url"] = base_url + if base_url: + llm_kwargs["base_url"] = base_url if api_key_env: api_key = os.environ.get(api_key_env) if api_key: @@ -68,7 +82,7 @@ class OpenAIClient(BaseLLMClient): elif self.base_url: llm_kwargs["base_url"] = self.base_url - # Forward user-provided kwargs + # Forward user-provided kwargs (takes precedence over auto-resolved tokens) for key in _PASSTHROUGH_KWARGS: if key in self.kwargs: llm_kwargs[key] = self.kwargs[key] @@ -77,6 +91,11 @@ class OpenAIClient(BaseLLMClient): # all model families. Third-party providers use Chat Completions. if self.provider == "openai": llm_kwargs["use_responses_api"] = True + # If no explicit api_key in kwargs, fall back to Codex OAuth token. + if "api_key" not in llm_kwargs: + codex_token = get_codex_token() + if codex_token: + llm_kwargs["api_key"] = codex_token return NormalizedChatOpenAI(**llm_kwargs) diff --git a/tradingagents/llm_clients/validators.py b/tradingagents/llm_clients/validators.py index 1e2388b3..8d27fda7 100644 --- a/tradingagents/llm_clients/validators.py +++ b/tradingagents/llm_clients/validators.py @@ -48,17 +48,20 @@ VALID_MODELS = { "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: From 24e97fb7030c582165f28feece85112451d9ccd7 Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 13:50:55 +0000 Subject: [PATCH 02/20] fix: use Responses API for Copilot models that don't support chat/completions gpt-5.4, gpt-5.4-mini, and codex variants only support /responses, not /chat/completions on the Copilot endpoint. Auto-detect and set use_responses_api=True for these models. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tradingagents/llm_clients/openai_client.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index f08ea84f..f0da2aa5 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -36,6 +36,19 @@ _PROVIDER_CONFIG = { } +# Models that only support the Responses API on the Copilot endpoint. +_COPILOT_RESPONSES_ONLY = 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", +)) + + +def _copilot_needs_responses_api(model: str) -> bool: + """Return True if the model requires /responses instead of /chat/completions.""" + return model in _COPILOT_RESPONSES_ONLY + + class OpenAIClient(BaseLLMClient): """Client for OpenAI, Ollama, OpenRouter, xAI, and GitHub Copilot providers. @@ -97,6 +110,11 @@ class OpenAIClient(BaseLLMClient): if codex_token: llm_kwargs["api_key"] = codex_token + # Copilot: newer models (gpt-5.4, codex variants) only support the + # Responses API (/responses), not Chat Completions (/chat/completions). + if self.provider == "copilot" and _copilot_needs_responses_api(self.model): + llm_kwargs["use_responses_api"] = True + return NormalizedChatOpenAI(**llm_kwargs) def validate_model(self) -> bool: From 888fdfbfb96f5001de9abe767d01b473c8349062 Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 13:59:09 +0000 Subject: [PATCH 03/20] refactor: move Copilot logic into standalone copilot_client.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tradingagents/llm_clients/copilot_client.py with all Copilot auth (gh CLI token, GraphQL URL resolution, required headers) and CopilotClient class inline — no separate auth module needed - Simplify openai_client.py: remove Copilot code, inline codex OAuth token logic directly (was tradingagents/auth/codex_token.py) - Remove tradingagents/auth/ folder entirely - Update factory.py to route 'copilot' -> CopilotClient - Simplify cli/utils.py to delegate to copilot_client helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + cli/utils.py | 83 +++------- tradingagents/auth/__init__.py | 4 - tradingagents/auth/codex_token.py | 109 ------------- tradingagents/auth/github_token.py | 68 --------- tradingagents/llm_clients/copilot_client.py | 154 +++++++++++++++++++ tradingagents/llm_clients/factory.py | 6 +- tradingagents/llm_clients/openai_client.py | 161 +++++++++++++------- 8 files changed, 282 insertions(+), 306 deletions(-) delete mode 100644 tradingagents/auth/__init__.py delete mode 100644 tradingagents/auth/codex_token.py delete mode 100644 tradingagents/auth/github_token.py create mode 100644 tradingagents/llm_clients/copilot_client.py 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/utils.py b/cli/utils.py index c48711df..c5e77e0e 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -289,43 +289,13 @@ def fetch_copilot_models() -> list[tuple[str, str]]: Returns a list of (display_label, model_id) tuples sorted by model ID. Requires authentication via ``gh auth login`` with a Copilot subscription. """ - import requests - from tradingagents.auth import get_github_token, COPILOT_HEADERS, get_copilot_api_url + from tradingagents.llm_clients.copilot_client import list_copilot_models - token = get_github_token() - if not token: - console.print("[red]No GitHub token available. Run `gh auth login` first.[/red]") - return [] - - try: - console.print("[dim]Fetching available Copilot models...[/dim]") - copilot_url = get_copilot_api_url() - headers = { - "Authorization": f"Bearer {token}", - **COPILOT_HEADERS, - } - resp = requests.get( - f"{copilot_url}/models", - headers=headers, - timeout=10, - ) - resp.raise_for_status() - data = resp.json() - - models = data.get("data", data) if isinstance(data, dict) else data - # Filter to chat-capable models (exclude embeddings) - 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 Exception as e: - console.print(f"[yellow]Warning: Could not fetch Copilot models: {e}[/yellow]") - return [] + 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]: @@ -374,42 +344,24 @@ def perform_copilot_oauth() -> bool: Returns True if a valid token with Copilot access is available, False otherwise. """ - from tradingagents.auth import get_github_token + from tradingagents.llm_clients.copilot_client import check_copilot_auth, _get_github_token - token = get_github_token() + token = _get_github_token() if token: - # Verify Copilot access - import requests - from tradingagents.auth import COPILOT_HEADERS, get_copilot_api_url - try: - copilot_url = get_copilot_api_url() - resp = requests.get( - f"{copilot_url}/models", - headers={"Authorization": f"Bearer {token}", **COPILOT_HEADERS}, - timeout=5, - ) - if resp.status_code == 200: - console.print("[green]✓ Authenticated with GitHub Copilot[/green]") - return True - else: - console.print( - f"[yellow]⚠ GitHub token found but Copilot access failed " - f"(HTTP {resp.status_code}). Check your Copilot subscription.[/yellow]" - ) - return False - except Exception: - # Network error — accept the token optimistically - console.print("[green]✓ Authenticated with GitHub CLI (Copilot access not verified)[/green]") + 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() - + 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 @@ -419,8 +371,7 @@ def perform_copilot_oauth() -> bool: console.print("[red]`gh auth login` failed.[/red]") return False - token = get_github_token() - if token: + if _get_github_token(): console.print("[green]✓ GitHub authentication successful![/green]") return True diff --git a/tradingagents/auth/__init__.py b/tradingagents/auth/__init__.py deleted file mode 100644 index 5ad89708..00000000 --- a/tradingagents/auth/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .codex_token import get_codex_token -from .github_token import get_github_token, get_copilot_api_url, COPILOT_HEADERS - -__all__ = ["get_codex_token", "get_github_token", "get_copilot_api_url", "COPILOT_HEADERS"] diff --git a/tradingagents/auth/codex_token.py b/tradingagents/auth/codex_token.py deleted file mode 100644 index 1039b7ba..00000000 --- a/tradingagents/auth/codex_token.py +++ /dev/null @@ -1,109 +0,0 @@ -"""OpenAI Codex OAuth token reader with auto-refresh. - -Reads credentials stored by the OpenAI Codex CLI at ~/.codex/auth.json. -Checks expiry and refreshes automatically via the OpenAI token endpoint -before returning a valid access token — the same pattern OpenClaw uses -with its auth-profiles.json token sink. - -Token refresh invalidates the previous refresh token, so only one tool -should hold the Codex credentials at a time (same caveat as OpenClaw). -""" - -import json -import time -from pathlib import Path -from typing import Optional - -import requests - -_AUTH_FILE = Path.home() / ".codex" / "auth.json" -_TOKEN_URL = "https://auth.openai.com/oauth/token" -# Refresh this many seconds before actual expiry to avoid edge-case failures. -_EXPIRY_BUFFER_SECS = 60 - - -def _load_auth() -> Optional[dict]: - """Load the Codex auth file, return None if missing or malformed.""" - if not _AUTH_FILE.exists(): - return None - try: - return json.loads(_AUTH_FILE.read_text()) - except (json.JSONDecodeError, OSError): - return None - - -def _save_auth(data: dict) -> None: - _AUTH_FILE.write_text(json.dumps(data, indent=2)) - - -def _is_expired(auth: dict) -> bool: - """Return True if the access token is expired (or close to expiring).""" - expires = auth.get("expires_at") or auth.get("tokens", {}).get("expires_at") - if expires is None: - # Fall back to decoding the JWT exp claim. - try: - import base64 - token = auth["tokens"]["access_token"] - payload = token.split(".")[1] - decoded = json.loads(base64.b64decode(payload + "==")) - expires = decoded.get("exp") - except Exception: - return False # Can't determine — assume valid. - return time.time() >= (expires - _EXPIRY_BUFFER_SECS) - - -def _refresh(auth: dict) -> dict: - """Exchange the refresh token for a new token pair and persist it.""" - refresh_token = auth["tokens"]["refresh_token"] - resp = requests.post( - _TOKEN_URL, - json={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - }, - headers={"Content-Type": "application/json"}, - timeout=15, - ) - resp.raise_for_status() - new_tokens = resp.json() - - # Merge new tokens back into the auth structure and persist. - auth["tokens"].update({ - "access_token": new_tokens["access_token"], - "refresh_token": new_tokens.get("refresh_token", refresh_token), - "expires_at": new_tokens.get("expires_in") and - int(time.time()) + int(new_tokens["expires_in"]), - }) - auth["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - _save_auth(auth) - return auth - - -def get_codex_token() -> Optional[str]: - """Return a valid OpenAI access token from the Codex CLI auth file. - - Resolution order: - 1. OPENAI_API_KEY environment variable (explicit key always wins) - 2. ~/.codex/auth.json — auto-refreshes if the access token is expired - - Returns None if no credentials are found. - """ - import os - explicit = os.environ.get("OPENAI_API_KEY") - if explicit: - return explicit - - auth = _load_auth() - if not auth or "tokens" not in auth: - return None - - # Refresh if expired. - if _is_expired(auth): - try: - auth = _refresh(auth) - except Exception: - # Refresh failed — return whatever token we have and let the - # API call surface a clearer error. - pass - - return auth["tokens"].get("access_token") diff --git a/tradingagents/auth/github_token.py b/tradingagents/auth/github_token.py deleted file mode 100644 index c2be73bc..00000000 --- a/tradingagents/auth/github_token.py +++ /dev/null @@ -1,68 +0,0 @@ -"""GitHub token retrieval for the GitHub Copilot API. - -Uses the ``gh`` CLI exclusively — no explicit API token or env var. -Run ``gh auth login`` once to authenticate; this module handles the rest. -""" - -import subprocess -from typing import Optional - - -def get_github_token() -> Optional[str]: - """Return a GitHub token obtained via the GitHub CLI (``gh auth token``). - - Returns None if the CLI is unavailable or the user is not logged in. - """ - 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. - - Queries the GitHub GraphQL API for the user's Copilot endpoints. - Falls back to the standard individual endpoint on failure. - """ - import requests - - token = get_github_token() - if not token: - return "https://api.individual.githubcopilot.com" - - 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 Exception: - pass - - return "https://api.individual.githubcopilot.com" - - -# Required headers for the Copilot inference API (reverse-engineered from the -# Copilot CLI at /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", -} diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py new file mode 100644 index 00000000..221032c3 --- /dev/null +++ b/tradingagents/llm_clients/copilot_client.py @@ -0,0 +1,154 @@ +"""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 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", + "api_key", "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 Exception: + 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 Exception: + 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 Exception: + return True # Network error — accept optimistically + + +class NormalizedChatOpenAI(ChatOpenAI): + """ChatOpenAI with normalized content output.""" + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + +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() + copilot_url = _get_copilot_api_url() + + llm_kwargs = { + "model": self.model, + "base_url": copilot_url, + "api_key": token or "copilot", + "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 cfa5e4ae..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 @@ -34,7 +35,10 @@ def create_llm_client( """ provider_lower = provider.lower() - if provider_lower in ("openai", "ollama", "openrouter", "copilot"): + 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) if provider_lower == "xai": diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index f0da2aa5..94db7401 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -1,11 +1,105 @@ +"""OpenAI-compatible LLM clients (OpenAI, xAI, OpenRouter, Ollama). + +OpenAI auth resolution order: + 1. ``api_key`` kwarg (explicit key always wins) + 2. ``OPENAI_API_KEY`` environment variable + 3. ``~/.codex/auth.json`` — OpenAI Codex CLI OAuth token (auto-refreshed) +""" + +import json import os +import time +from pathlib import Path from typing import Any, Optional +import requests from langchain_openai import ChatOpenAI from .base_client import BaseLLMClient, normalize_content from .validators import validate_model -from ..auth import get_codex_token, get_github_token, get_copilot_api_url, COPILOT_HEADERS + +# --------------------------------------------------------------------------- +# Codex OAuth token reader (inlined from auth/codex_token.py) +# --------------------------------------------------------------------------- + +_CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json" +_CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token" +_EXPIRY_BUFFER_SECS = 60 + + +def _load_codex_auth() -> Optional[dict]: + if not _CODEX_AUTH_FILE.exists(): + return None + try: + return json.loads(_CODEX_AUTH_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return None + + +def _codex_token_expired(auth: dict) -> bool: + expires = auth.get("expires_at") or auth.get("tokens", {}).get("expires_at") + if expires is None: + try: + import base64 + token = auth["tokens"]["access_token"] + payload = token.split(".")[1] + decoded = json.loads(base64.b64decode(payload + "==")) + expires = decoded.get("exp") + except Exception: + return False + return time.time() >= (expires - _EXPIRY_BUFFER_SECS) + + +def _refresh_codex_token(auth: dict) -> dict: + refresh_token = auth["tokens"]["refresh_token"] + resp = requests.post( + _CODEX_TOKEN_URL, + json={"grant_type": "refresh_token", "refresh_token": refresh_token}, + headers={"Content-Type": "application/json"}, + timeout=15, + ) + resp.raise_for_status() + new_tokens = resp.json() + auth["tokens"].update({ + "access_token": new_tokens["access_token"], + "refresh_token": new_tokens.get("refresh_token", refresh_token), + "expires_at": new_tokens.get("expires_in") and int(time.time()) + int(new_tokens["expires_in"]), + }) + auth["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + _CODEX_AUTH_FILE.write_text(json.dumps(auth, indent=2)) + return auth + + +def _get_codex_token() -> Optional[str]: + """Return a valid OpenAI token from OPENAI_API_KEY or ~/.codex/auth.json.""" + explicit = os.environ.get("OPENAI_API_KEY") + if explicit: + return explicit + auth = _load_codex_auth() + if not auth or "tokens" not in auth: + return None + if _codex_token_expired(auth): + try: + auth = _refresh_codex_token(auth) + except Exception: + pass + return auth["tokens"].get("access_token") + + +# --------------------------------------------------------------------------- +# OpenAI-compatible client +# --------------------------------------------------------------------------- + +_PASSTHROUGH_KWARGS = ( + "timeout", "max_retries", "reasoning_effort", + "api_key", "callbacks", "http_client", "http_async_client", +) + +_PROVIDER_CONFIG = { + "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), + "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), + "ollama": ("http://localhost:11434/v1", None), +} class NormalizedChatOpenAI(ChatOpenAI): @@ -19,44 +113,14 @@ class NormalizedChatOpenAI(ChatOpenAI): def invoke(self, input, config=None, **kwargs): return normalize_content(super().invoke(input, config, **kwargs)) -# Kwargs forwarded from user config to ChatOpenAI -_PASSTHROUGH_KWARGS = ( - "timeout", "max_retries", "reasoning_effort", - "api_key", "callbacks", "http_client", "http_async_client", -) - -# Provider base URLs and API key env vars. -# Copilot: uses the GitHub Copilot inference API, authenticated via ``gh`` -# CLI token with Copilot-specific headers. No env var needed. -_PROVIDER_CONFIG = { - "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), - "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), - "ollama": ("http://localhost:11434/v1", None), - "copilot": (None, None), # base_url resolved at runtime via GraphQL -} - - -# Models that only support the Responses API on the Copilot endpoint. -_COPILOT_RESPONSES_ONLY = 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", -)) - - -def _copilot_needs_responses_api(model: str) -> bool: - """Return True if the model requires /responses instead of /chat/completions.""" - return model in _COPILOT_RESPONSES_ONLY - class OpenAIClient(BaseLLMClient): - """Client for OpenAI, Ollama, OpenRouter, xAI, and GitHub Copilot providers. + """Client for OpenAI, xAI, OpenRouter, and Ollama providers. For native OpenAI models, uses the Responses API (/v1/responses) which supports reasoning_effort with function tools across all model families (GPT-4.1, GPT-5). Third-party compatible providers (xAI, OpenRouter, - Ollama) use standard Chat Completions. GitHub Copilot uses the Copilot - inference API with special headers. + Ollama) use standard Chat Completions. """ def __init__( @@ -73,19 +137,9 @@ class OpenAIClient(BaseLLMClient): """Return configured ChatOpenAI instance.""" llm_kwargs = {"model": self.model} - # Provider-specific base URL and auth - if self.provider == "copilot": - # GitHub Copilot: resolve base URL and inject required headers - copilot_url = get_copilot_api_url() - llm_kwargs["base_url"] = copilot_url - token = get_github_token() - if token: - llm_kwargs["api_key"] = token - llm_kwargs["default_headers"] = dict(COPILOT_HEADERS) - elif self.provider in _PROVIDER_CONFIG: + if self.provider in _PROVIDER_CONFIG: base_url, api_key_env = _PROVIDER_CONFIG[self.provider] - if base_url: - llm_kwargs["base_url"] = base_url + llm_kwargs["base_url"] = base_url if api_key_env: api_key = os.environ.get(api_key_env) if api_key: @@ -95,28 +149,19 @@ class OpenAIClient(BaseLLMClient): elif self.base_url: llm_kwargs["base_url"] = self.base_url - # Forward user-provided kwargs (takes precedence over auto-resolved tokens) for key in _PASSTHROUGH_KWARGS: if key in self.kwargs: llm_kwargs[key] = self.kwargs[key] - # Native OpenAI: use Responses API for consistent behavior across - # all model families. Third-party providers use Chat Completions. if self.provider == "openai": llm_kwargs["use_responses_api"] = True - # If no explicit api_key in kwargs, fall back to Codex OAuth token. if "api_key" not in llm_kwargs: - codex_token = get_codex_token() - if codex_token: - llm_kwargs["api_key"] = codex_token - - # Copilot: newer models (gpt-5.4, codex variants) only support the - # Responses API (/responses), not Chat Completions (/chat/completions). - if self.provider == "copilot" and _copilot_needs_responses_api(self.model): - llm_kwargs["use_responses_api"] = True + token = _get_codex_token() + if token: + llm_kwargs["api_key"] = token return NormalizedChatOpenAI(**llm_kwargs) def validate_model(self) -> bool: - """Validate model for the provider.""" return validate_model(self.provider, self.model) + From 894b7bcba0b2ce65285833576448d00100782a77 Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:01:30 +0000 Subject: [PATCH 04/20] update --- .env.example | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.env.example b/.env.example index d8de7ad5..c8adda53 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,4 @@ OPENAI_API_KEY= GOOGLE_API_KEY= ANTHROPIC_API_KEY= XAI_API_KEY= -OPENROUTER_API_KEY= - -# GitHub Copilot authenticates via the GitHub CLI (`gh auth login`). -# No API key needed — the token from `gh auth token` is used automatically. -# Requires an active Copilot subscription (Pro/Pro+). +OPENROUTER_API_KEY= \ No newline at end of file From a84c69e42fcb7efcd51557a9ca3078d070964f8a Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:03:27 +0000 Subject: [PATCH 05/20] update --- tradingagents/llm_clients/openai_client.py | 122 ++++----------------- 1 file changed, 20 insertions(+), 102 deletions(-) diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 94db7401..ca85ead0 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -1,106 +1,11 @@ -"""OpenAI-compatible LLM clients (OpenAI, xAI, OpenRouter, Ollama). - -OpenAI auth resolution order: - 1. ``api_key`` kwarg (explicit key always wins) - 2. ``OPENAI_API_KEY`` environment variable - 3. ``~/.codex/auth.json`` — OpenAI Codex CLI OAuth token (auto-refreshed) -""" - -import json import os -import time -from pathlib import Path from typing import Any, Optional -import requests from langchain_openai import ChatOpenAI from .base_client import BaseLLMClient, normalize_content from .validators import validate_model -# --------------------------------------------------------------------------- -# Codex OAuth token reader (inlined from auth/codex_token.py) -# --------------------------------------------------------------------------- - -_CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json" -_CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token" -_EXPIRY_BUFFER_SECS = 60 - - -def _load_codex_auth() -> Optional[dict]: - if not _CODEX_AUTH_FILE.exists(): - return None - try: - return json.loads(_CODEX_AUTH_FILE.read_text()) - except (json.JSONDecodeError, OSError): - return None - - -def _codex_token_expired(auth: dict) -> bool: - expires = auth.get("expires_at") or auth.get("tokens", {}).get("expires_at") - if expires is None: - try: - import base64 - token = auth["tokens"]["access_token"] - payload = token.split(".")[1] - decoded = json.loads(base64.b64decode(payload + "==")) - expires = decoded.get("exp") - except Exception: - return False - return time.time() >= (expires - _EXPIRY_BUFFER_SECS) - - -def _refresh_codex_token(auth: dict) -> dict: - refresh_token = auth["tokens"]["refresh_token"] - resp = requests.post( - _CODEX_TOKEN_URL, - json={"grant_type": "refresh_token", "refresh_token": refresh_token}, - headers={"Content-Type": "application/json"}, - timeout=15, - ) - resp.raise_for_status() - new_tokens = resp.json() - auth["tokens"].update({ - "access_token": new_tokens["access_token"], - "refresh_token": new_tokens.get("refresh_token", refresh_token), - "expires_at": new_tokens.get("expires_in") and int(time.time()) + int(new_tokens["expires_in"]), - }) - auth["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - _CODEX_AUTH_FILE.write_text(json.dumps(auth, indent=2)) - return auth - - -def _get_codex_token() -> Optional[str]: - """Return a valid OpenAI token from OPENAI_API_KEY or ~/.codex/auth.json.""" - explicit = os.environ.get("OPENAI_API_KEY") - if explicit: - return explicit - auth = _load_codex_auth() - if not auth or "tokens" not in auth: - return None - if _codex_token_expired(auth): - try: - auth = _refresh_codex_token(auth) - except Exception: - pass - return auth["tokens"].get("access_token") - - -# --------------------------------------------------------------------------- -# OpenAI-compatible client -# --------------------------------------------------------------------------- - -_PASSTHROUGH_KWARGS = ( - "timeout", "max_retries", "reasoning_effort", - "api_key", "callbacks", "http_client", "http_async_client", -) - -_PROVIDER_CONFIG = { - "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), - "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), - "ollama": ("http://localhost:11434/v1", None), -} - class NormalizedChatOpenAI(ChatOpenAI): """ChatOpenAI with normalized content output. @@ -113,9 +18,22 @@ class NormalizedChatOpenAI(ChatOpenAI): def invoke(self, input, config=None, **kwargs): return normalize_content(super().invoke(input, config, **kwargs)) +# Kwargs forwarded from user config to ChatOpenAI +_PASSTHROUGH_KWARGS = ( + "timeout", "max_retries", "reasoning_effort", + "api_key", "callbacks", "http_client", "http_async_client", +) + +# Provider base URLs and API key env vars +_PROVIDER_CONFIG = { + "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), + "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), + "ollama": ("http://localhost:11434/v1", None), +} + class OpenAIClient(BaseLLMClient): - """Client for OpenAI, xAI, OpenRouter, and Ollama providers. + """Client for OpenAI, Ollama, OpenRouter, and xAI providers. For native OpenAI models, uses the Responses API (/v1/responses) which supports reasoning_effort with function tools across all model families @@ -137,6 +55,7 @@ class OpenAIClient(BaseLLMClient): """Return configured ChatOpenAI instance.""" llm_kwargs = {"model": self.model} + # Provider-specific base URL and auth if self.provider in _PROVIDER_CONFIG: base_url, api_key_env = _PROVIDER_CONFIG[self.provider] llm_kwargs["base_url"] = base_url @@ -149,19 +68,18 @@ class OpenAIClient(BaseLLMClient): elif self.base_url: llm_kwargs["base_url"] = self.base_url + # Forward user-provided kwargs for key in _PASSTHROUGH_KWARGS: if key in self.kwargs: llm_kwargs[key] = self.kwargs[key] + # Native OpenAI: use Responses API for consistent behavior across + # all model families. Third-party providers use Chat Completions. if self.provider == "openai": llm_kwargs["use_responses_api"] = True - if "api_key" not in llm_kwargs: - token = _get_codex_token() - if token: - llm_kwargs["api_key"] = token return NormalizedChatOpenAI(**llm_kwargs) def validate_model(self) -> bool: - return validate_model(self.provider, self.model) - + """Validate model for the provider.""" + return validate_model(self.provider, self.model) \ No newline at end of file From fcca3236e9acb9f5e0575ae6b90acd6d2284b6e3 Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:12:01 +0000 Subject: [PATCH 06/20] update --- cli/main.py | 12 +++++------- cli/utils.py | 8 ++++++-- tradingagents/default_config.py | 3 --- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cli/main.py b/cli/main.py index c0048bfb..57e42745 100644 --- a/cli/main.py +++ b/cli/main.py @@ -546,10 +546,8 @@ def get_user_selections(): ) selected_llm_provider, backend_url = select_llm_provider() - provider_id = selected_llm_provider.lower() - # GitHub Copilot: run OAuth before proceeding - if provider_id == "copilot": + if selected_llm_provider.lower() == "copilot": console.print( create_question_box( "Step 5b: Copilot Auth", @@ -565,15 +563,15 @@ def get_user_selections(): "Step 6: Thinking Agents", "Select your thinking agents for analysis" ) ) - selected_shallow_thinker = select_shallow_thinking_agent(provider_id) - selected_deep_thinker = select_deep_thinking_agent(provider_id) + selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) + selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider) # Step 7: Provider-specific thinking configuration thinking_level = None reasoning_effort = None anthropic_effort = None - provider_lower = provider_id + provider_lower = selected_llm_provider.lower() if provider_lower == "google": console.print( create_question_box( @@ -604,7 +602,7 @@ def get_user_selections(): "analysis_date": analysis_date, "analysts": selected_analysts, "research_depth": selected_research_depth, - "llm_provider": provider_id, + "llm_provider": selected_llm_provider.lower(), "backend_url": backend_url, "shallow_thinker": selected_shallow_thinker, "deep_thinker": selected_deep_thinker, diff --git a/cli/utils.py b/cli/utils.py index c5e77e0e..db275806 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -137,7 +137,9 @@ def select_research_depth() -> int: 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"), @@ -170,7 +172,7 @@ def select_shallow_thinking_agent(provider) -> str: ("GPT-OSS:latest (20B, local)", "gpt-oss:latest"), ("GLM-4.7-Flash:latest (30B, local)", "glm-4.7-flash:latest"), ], - "copilot": [], # populated dynamically by fetch_copilot_models() + "copilot": [], } if provider.lower() == "copilot": @@ -212,7 +214,9 @@ def select_shallow_thinking_agent(provider) -> str: def select_deep_thinking_agent(provider) -> str: """Select deep thinking llm engine using an interactive selection.""" - # Ordering: heavy → medium → light (most capable first for deep tasks) + # 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 DEEP_AGENT_OPTIONS = { "openai": [ ("GPT-5.4 - Latest frontier, 1M context", "gpt-5.4"), diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 5c3c1d87..898e1e1e 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -8,9 +8,6 @@ DEFAULT_CONFIG = { "dataflows/data_cache", ), # LLM settings - # Set llm_provider to "copilot" to use GitHub Copilot (no explicit API key - # needed — authenticates via `gh auth token` from the GitHub CLI). - # Available models are fetched dynamically from the Copilot inference API. "llm_provider": "openai", "deep_think_llm": "gpt-5.2", "quick_think_llm": "gpt-5-mini", From 6d53f33df06f74aab13a5c7d194809131c6d502f Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:45:33 +0000 Subject: [PATCH 07/20] fix: verify Copilot subscription access after gh auth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful gh auth login, call check_copilot_auth() to confirm the new token actually has Copilot access — not just that a token exists. Prevents proceeding with an account that has no Copilot subscription. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index db275806..0414cb9f 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -375,11 +375,11 @@ def perform_copilot_oauth() -> bool: console.print("[red]`gh auth login` failed.[/red]") return False - if _get_github_token(): - console.print("[green]✓ GitHub authentication successful![/green]") + if check_copilot_auth(): + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") return True - console.print("[red]Could not retrieve token after login.[/red]") + console.print("[red]Could not retrieve token or verify Copilot access after login.[/red]") return False From d6ef3c5d4d2d49c2e0ffa64f748f9c317d9cd2ff Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:46:25 +0000 Subject: [PATCH 08/20] fix: raise explicit error when gh token is missing in CopilotClient Replace the 'copilot' fallback string with a RuntimeError so users get a clear message instead of a cryptic auth failure from the API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tradingagents/llm_clients/copilot_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index 221032c3..66bfba25 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -132,12 +132,16 @@ class CopilotClient(BaseLLMClient): 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 or "copilot", + "api_key": token, "default_headers": dict(_COPILOT_HEADERS), } From 902ad8b93244c06917372c41f3926ada688e219a Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:47:20 +0000 Subject: [PATCH 09/20] refactor: extract _resolve_model_options() to remove duplicated logic Both select_shallow_thinking_agent and select_deep_thinking_agent had identical Copilot/static model resolution blocks. Extract into a single helper to improve maintainability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/utils.py | 67 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index 0414cb9f..d95dfd3b 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -134,6 +134,28 @@ 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.""" @@ -175,16 +197,7 @@ def select_shallow_thinking_agent(provider) -> str: "copilot": [], } - if provider.lower() == "copilot": - options = fetch_copilot_models() - if not options: - console.print("[red]No Copilot models available. Exiting...[/red]") - exit(1) - else: - options = SHALLOW_AGENT_OPTIONS.get(provider.lower()) - if not options: - console.print(f"[red]No models available for provider '{provider}'. Exiting...[/red]") - exit(1) + options = _resolve_model_options(provider, SHALLOW_AGENT_OPTIONS) choice = questionary.select( "Select Your [Quick-Thinking LLM Engine]:", @@ -254,16 +267,7 @@ def select_deep_thinking_agent(provider) -> str: "copilot": [], # populated dynamically by fetch_copilot_models() } - if provider.lower() == "copilot": - options = fetch_copilot_models() - if not options: - console.print("[red]No Copilot models available. Exiting...[/red]") - exit(1) - else: - options = DEEP_AGENT_OPTIONS.get(provider.lower()) - if not options: - console.print(f"[red]No models available for provider '{provider}'. Exiting...[/red]") - exit(1) + options = _resolve_model_options(provider, DEEP_AGENT_OPTIONS) choice = questionary.select( "Select Your [Deep-Thinking LLM Engine]:", @@ -287,6 +291,29 @@ def select_deep_thinking_agent(provider) -> str: return choice +def _resolve_model_options( + provider: str, static_options: dict +) -> list[tuple[str, str]]: + """Return the model list for the given provider. + + For Copilot, fetches live from the inference API. + For all others, 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) + return options + + 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 fetch_copilot_models() -> list[tuple[str, str]]: """Fetch models from the GitHub Copilot inference API. From 706e6bf3a9b32e371e70158b5bfd51a7148fe7aa Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:52:09 +0000 Subject: [PATCH 10/20] Update cli/utils.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- cli/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index d95dfd3b..df75c775 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -227,8 +227,8 @@ def select_shallow_thinking_agent(provider) -> str: def select_deep_thinking_agent(provider) -> str: """Select deep 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) + # 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": [ From e0b7fed7f4e137b5bd552a6bb27733db226a5348 Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:52:33 +0000 Subject: [PATCH 11/20] Update cli/utils.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- cli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/utils.py b/cli/utils.py index df75c775..95494a5a 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -375,7 +375,7 @@ def perform_copilot_oauth() -> bool: 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 + from tradingagents.llm_clients.copilot_client import check_copilot_auth, get_github_token token = _get_github_token() if token: From ee1c3a69eeb83aa78611a197122c823aacfd0fa7 Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:52:40 +0000 Subject: [PATCH 12/20] Update tradingagents/llm_clients/copilot_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tradingagents/llm_clients/copilot_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index 66bfba25..541cfee5 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -93,7 +93,7 @@ def list_copilot_models() -> list[tuple[str, str]]: 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 Exception: + except requests.exceptions.RequestException: return [] From e76a6742bed2896299a91a426b3093aec4e900a6 Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:52:49 +0000 Subject: [PATCH 13/20] Update tradingagents/llm_clients/copilot_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tradingagents/llm_clients/copilot_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index 541cfee5..8d2005be 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -67,7 +67,7 @@ def _get_copilot_api_url() -> str: api = resp.json()["data"]["viewer"]["copilotEndpoints"]["api"] if api: return api.rstrip("/") - except Exception: + except requests.exceptions.RequestException: pass return "https://api.individual.githubcopilot.com" From ab1b2883ade127f2111c3287806f644671ac8cca Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 14:58:53 +0000 Subject: [PATCH 14/20] update --- cli/utils.py | 2 +- tradingagents/llm_clients/copilot_client.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index 95494a5a..7607c25e 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -377,7 +377,7 @@ def perform_copilot_oauth() -> bool: """ from tradingagents.llm_clients.copilot_client import check_copilot_auth, get_github_token - token = _get_github_token() + token = get_github_token() if token: if check_copilot_auth(): console.print("[green]✓ Authenticated with GitHub Copilot[/green]") diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index 8d2005be..fb9db988 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -37,7 +37,7 @@ _PASSTHROUGH_KWARGS = ( ) -def _get_github_token() -> Optional[str]: +def get_github_token() -> Optional[str]: """Return a GitHub token via the ``gh`` CLI.""" try: result = subprocess.run( @@ -54,7 +54,7 @@ def _get_github_token() -> Optional[str]: 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() + token = get_github_token() if token: try: resp = requests.post( @@ -78,7 +78,7 @@ def list_copilot_models() -> list[tuple[str, str]]: 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() + token = get_github_token() if not token: return [] try: @@ -99,7 +99,7 @@ def list_copilot_models() -> list[tuple[str, str]]: def check_copilot_auth() -> bool: """Return True if a GitHub token with Copilot access is available.""" - token = _get_github_token() + token = get_github_token() if not token: return False try: @@ -131,7 +131,7 @@ class CopilotClient(BaseLLMClient): def get_llm(self) -> Any: """Return configured ChatOpenAI instance pointed at the Copilot API.""" - token = _get_github_token() + token = get_github_token() if not token: raise RuntimeError( "No GitHub token found. Run `gh auth login` to authenticate." From d38d5e2ecb91d7b210376f3fbf471587f333c625 Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:19:04 +0000 Subject: [PATCH 15/20] Update tradingagents/llm_clients/copilot_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tradingagents/llm_clients/copilot_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index fb9db988..845b9f4b 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -110,7 +110,7 @@ def check_copilot_auth() -> bool: timeout=5, ) return resp.status_code == 200 - except Exception: + except requests.exceptions.RequestException: return True # Network error — accept optimistically From 7064884b4f1ed3959fe854b52aaad46e61109798 Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 15:20:15 +0000 Subject: [PATCH 16/20] removed the duplicate _resolve_model_options definition from utils.py (line 137), leaving the original helper in place and keeping all callers unchanged --- cli/utils.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index 7607c25e..56c7aa6a 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -291,29 +291,6 @@ def select_deep_thinking_agent(provider) -> str: return choice -def _resolve_model_options( - provider: str, static_options: dict -) -> list[tuple[str, str]]: - """Return the model list for the given provider. - - For Copilot, fetches live from the inference API. - For all others, 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) - return options - - 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 fetch_copilot_models() -> list[tuple[str, str]]: """Fetch models from the GitHub Copilot inference API. From b0754b3e3980ee7dee81d56297ab4ecf1356e14d Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:37:36 +0000 Subject: [PATCH 17/20] Update tradingagents/llm_clients/copilot_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tradingagents/llm_clients/copilot_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index 845b9f4b..3d2eb0c5 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -111,7 +111,7 @@ def check_copilot_auth() -> bool: ) return resp.status_code == 200 except requests.exceptions.RequestException: - return True # Network error — accept optimistically + return False # Network error should be treated as an auth failure class NormalizedChatOpenAI(ChatOpenAI): From 6d3b19d705df40429cba06fd91cdf945f65daa82 Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:38:10 +0000 Subject: [PATCH 18/20] Update cli/utils.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- cli/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index 56c7aa6a..175022e3 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -374,9 +374,13 @@ def perform_copilot_oauth() -> bool: console.print("[red]GitHub authentication skipped. Exiting...[/red]") return False - result = subprocess.run(["gh", "auth", "login"]) - if result.returncode != 0: - console.print("[red]`gh auth login` failed.[/red]") + 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(): From 1cb6d4e8822e4b40536a956223b19d749ef22d22 Mon Sep 17 00:00:00 2001 From: Michael Liu <34015028+ljxw88@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:38:28 +0000 Subject: [PATCH 19/20] Update tradingagents/llm_clients/copilot_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tradingagents/llm_clients/copilot_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index 3d2eb0c5..d91aa21d 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -33,7 +33,7 @@ _RESPONSES_ONLY_MODELS = frozenset(( _PASSTHROUGH_KWARGS = ( "timeout", "max_retries", "reasoning_effort", - "api_key", "callbacks", "http_client", "http_async_client", + "callbacks", "http_client", "http_async_client", ) From 57a2ae5438cd057a246b637b2e3093c970a6a338 Mon Sep 17 00:00:00 2001 From: Jiaxu Liu Date: Mon, 23 Mar 2026 15:46:04 +0000 Subject: [PATCH 20/20] patched the Copilot client to sanitize malformed usage metadata before langchain_openai processes it --- tradingagents/llm_clients/copilot_client.py | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tradingagents/llm_clients/copilot_client.py b/tradingagents/llm_clients/copilot_client.py index d91aa21d..e1867d8b 100644 --- a/tradingagents/llm_clients/copilot_client.py +++ b/tradingagents/llm_clients/copilot_client.py @@ -8,6 +8,7 @@ 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 @@ -117,10 +118,51 @@ def check_copilot_auth() -> bool: 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.