Merge afdd7abaff into 4641c03340
This commit is contained in:
commit
f289a156e6
|
|
@ -217,3 +217,6 @@ __marimo__/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
**/data_cache/
|
**/data_cache/
|
||||||
|
|
||||||
|
# Research Results
|
||||||
|
results/*
|
||||||
17
cli/main.py
17
cli/main.py
|
|
@ -547,15 +547,26 @@ def get_user_selections():
|
||||||
)
|
)
|
||||||
selected_research_depth = select_research_depth()
|
selected_research_depth = select_research_depth()
|
||||||
|
|
||||||
# Step 6: LLM Provider
|
# Step 5: LLM Provider
|
||||||
console.print(
|
console.print(
|
||||||
create_question_box(
|
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()
|
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(
|
console.print(
|
||||||
create_question_box(
|
create_question_box(
|
||||||
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
||||||
|
|
|
||||||
176
cli/utils.py
176
cli/utils.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
import subprocess
|
||||||
import questionary
|
import questionary
|
||||||
from typing import List, Optional, Tuple, Dict
|
from typing import List, Optional, Tuple, Dict
|
||||||
|
|
||||||
|
|
@ -134,13 +135,76 @@ def select_research_depth() -> int:
|
||||||
return choice
|
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:
|
def select_shallow_thinking_agent(provider) -> str:
|
||||||
"""Select shallow thinking llm engine using an interactive selection."""
|
"""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(
|
choice = questionary.select(
|
||||||
"Select Your [Quick-Thinking LLM Engine]:",
|
"Select Your [Quick-Thinking LLM Engine]:",
|
||||||
choices=[
|
choices=[
|
||||||
questionary.Choice(display, value=value)
|
questionary.Choice(display, value=value)
|
||||||
|
for display, value in options
|
||||||
for display, value in get_model_options(provider, "quick")
|
for display, value in get_model_options(provider, "quick")
|
||||||
],
|
],
|
||||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
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:
|
def select_deep_thinking_agent(provider) -> str:
|
||||||
"""Select deep thinking llm engine using an interactive selection."""
|
"""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(
|
choice = questionary.select(
|
||||||
"Select Your [Deep-Thinking LLM Engine]:",
|
"Select Your [Deep-Thinking LLM Engine]:",
|
||||||
choices=[
|
choices=[
|
||||||
questionary.Choice(display, value=value)
|
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",
|
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||||
style=questionary.Style(
|
style=questionary.Style(
|
||||||
|
|
@ -187,9 +293,23 @@ def select_deep_thinking_agent(provider) -> str:
|
||||||
|
|
||||||
return choice
|
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]:
|
def select_llm_provider() -> tuple[str, str]:
|
||||||
"""Select the OpenAI api url using interactive selection."""
|
"""Select the LLM provider using interactive selection."""
|
||||||
# Define OpenAI api options with their corresponding endpoints
|
|
||||||
BASE_URLS = [
|
BASE_URLS = [
|
||||||
("OpenAI", "https://api.openai.com/v1"),
|
("OpenAI", "https://api.openai.com/v1"),
|
||||||
("Google", "https://generativelanguage.googleapis.com/v1"),
|
("Google", "https://generativelanguage.googleapis.com/v1"),
|
||||||
|
|
@ -197,6 +317,7 @@ def select_llm_provider() -> tuple[str, str]:
|
||||||
("xAI", "https://api.x.ai/v1"),
|
("xAI", "https://api.x.ai/v1"),
|
||||||
("Openrouter", "https://openrouter.ai/api/v1"),
|
("Openrouter", "https://openrouter.ai/api/v1"),
|
||||||
("Ollama", "http://localhost:11434/v1"),
|
("Ollama", "http://localhost:11434/v1"),
|
||||||
|
("Copilot", ""), # resolved at runtime via GraphQL
|
||||||
]
|
]
|
||||||
|
|
||||||
choice = questionary.select(
|
choice = questionary.select(
|
||||||
|
|
@ -216,7 +337,7 @@ def select_llm_provider() -> tuple[str, str]:
|
||||||
).ask()
|
).ask()
|
||||||
|
|
||||||
if choice is None:
|
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)
|
exit(1)
|
||||||
|
|
||||||
display_name, url = choice
|
display_name, url = choice
|
||||||
|
|
@ -225,6 +346,53 @@ def select_llm_provider() -> tuple[str, str]:
|
||||||
return display_name, 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:
|
def ask_openai_reasoning_effort() -> str:
|
||||||
"""Ask for OpenAI reasoning effort level."""
|
"""Ask for OpenAI reasoning effort level."""
|
||||||
choices = [
|
choices = [
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -2,6 +2,7 @@ from typing import Optional
|
||||||
|
|
||||||
from .base_client import BaseLLMClient
|
from .base_client import BaseLLMClient
|
||||||
from .openai_client import OpenAIClient
|
from .openai_client import OpenAIClient
|
||||||
|
from .copilot_client import CopilotClient
|
||||||
from .anthropic_client import AnthropicClient
|
from .anthropic_client import AnthropicClient
|
||||||
from .google_client import GoogleClient
|
from .google_client import GoogleClient
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ def create_llm_client(
|
||||||
"""Create an LLM client for the specified provider.
|
"""Create an LLM client for the specified provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: LLM provider (openai, anthropic, google, xai, ollama, openrouter)
|
provider: LLM provider (openai, anthropic, google, xai, ollama, openrouter, copilot)
|
||||||
model: Model name/identifier
|
model: Model name/identifier
|
||||||
base_url: Optional base URL for API endpoint
|
base_url: Optional base URL for API endpoint
|
||||||
**kwargs: Additional provider-specific arguments
|
**kwargs: Additional provider-specific arguments
|
||||||
|
|
@ -34,6 +35,9 @@ def create_llm_client(
|
||||||
"""
|
"""
|
||||||
provider_lower = provider.lower()
|
provider_lower = provider.lower()
|
||||||
|
|
||||||
|
if provider_lower == "copilot":
|
||||||
|
return CopilotClient(model, base_url, **kwargs)
|
||||||
|
|
||||||
if provider_lower in ("openai", "ollama", "openrouter"):
|
if provider_lower in ("openai", "ollama", "openrouter"):
|
||||||
return OpenAIClient(model, base_url, provider=provider_lower, **kwargs)
|
return OpenAIClient(model, base_url, provider=provider_lower, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,63 @@ from .model_catalog import get_known_models
|
||||||
|
|
||||||
|
|
||||||
VALID_MODELS = {
|
VALID_MODELS = {
|
||||||
provider: models
|
"openai": [
|
||||||
for provider, models in get_known_models().items()
|
# GPT-5 series
|
||||||
if provider not in ("ollama", "openrouter")
|
"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:
|
def validate_model(provider: str, model: str) -> bool:
|
||||||
"""Check if model name is valid for the given provider.
|
"""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()
|
provider_lower = provider.lower()
|
||||||
|
|
||||||
if provider_lower in ("ollama", "openrouter"):
|
if provider_lower in ("ollama", "openrouter", "copilot"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if provider_lower not in VALID_MODELS:
|
if provider_lower not in VALID_MODELS:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue