This commit is contained in:
Michael Liu 2026-03-31 13:23:43 +01:00 committed by GitHub
commit f289a156e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 447 additions and 18 deletions

View File

@ -3,4 +3,4 @@ OPENAI_API_KEY=
GOOGLE_API_KEY=
ANTHROPIC_API_KEY=
XAI_API_KEY=
OPENROUTER_API_KEY=
OPENROUTER_API_KEY=

3
.gitignore vendored
View File

@ -217,3 +217,6 @@ __marimo__/
# Cache
**/data_cache/
# Research Results
results/*

View File

@ -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"

View File

@ -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 = [

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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: