From b161fa4f9a7521d9868f2bd83d240cc9a15dc21f Mon Sep 17 00:00:00 2001 From: Jeroen Evens Date: Fri, 6 Feb 2026 18:38:18 +0100 Subject: [PATCH 1/3] [github] Add Github Copilot --- nanobot/cli/commands.py | 4 +++- nanobot/config/schema.py | 4 +++- nanobot/providers/litellm_provider.py | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5280d0f..ff3493a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -892,7 +892,9 @@ def status(): p = getattr(config.providers, spec.name, None) if p is None: continue - if spec.is_local: + if spec.is_oauth: + console.print(f"{spec.label}: [green]✓ (OAuth)[/green]") + elif spec.is_local: # Local deployments show api_base instead of api_key if p.api_base: console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 76ec74d..99c659c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -298,7 +298,9 @@ class Config(BaseSettings): if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) - if p and p.api_key: + if p is None: + continue + if p.api_key: return p, spec.name return None, None diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..43dfbb5 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -38,6 +38,9 @@ class LiteLLMProvider(LLMProvider): # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) + # Detect GitHub Copilot (uses OAuth device flow, no API key) + self.is_github_copilot = "github_copilot" in default_model + # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) @@ -76,6 +79,10 @@ class LiteLLMProvider(LLMProvider): def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" + # GitHub Copilot models pass through directly + if self.is_github_copilot: + return model + if self._gateway: # Gateway mode: apply gateway prefix, skip provider-specific prefixes prefix = self._gateway.litellm_prefix From 16127d49f98cccb47fdf99306f41c38934821bc9 Mon Sep 17 00:00:00 2001 From: Jeroen Evens Date: Tue, 17 Feb 2026 22:50:39 +0100 Subject: [PATCH 2/3] [github] Fix Oauth login --- nanobot/cli/commands.py | 112 +++++++++++++++++++------- nanobot/providers/litellm_provider.py | 7 -- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ff3493a..c1104da 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -915,40 +915,92 @@ app.add_typer(provider_app, name="provider") @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"), + provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"), ): """Authenticate with an OAuth provider.""" - console.print(f"{__logo__} OAuth Login - {provider}\n") + from nanobot.providers.registry import PROVIDERS - if provider == "openai-codex": - try: - from oauth_cli_kit import get_token, login_oauth_interactive - token = None - try: - token = get_token() - except Exception: - token = None - if not (token and token.access): - console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") - console.print("A browser window may open for you to authenticate.\n") - token = login_oauth_interactive( - print_fn=lambda s: console.print(s), - prompt_fn=lambda s: typer.prompt(s), - ) - if not (token and token.access): - console.print("[red]✗ Authentication failed[/red]") - raise typer.Exit(1) - console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") - except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") - raise typer.Exit(1) - except Exception as e: - console.print(f"[red]Authentication error: {e}[/red]") - raise typer.Exit(1) - else: + # Normalize: "github-copilot" → "github_copilot" + provider_key = provider.replace("-", "_") + + # Validate against the registry — only OAuth providers support login + spec = None + for s in PROVIDERS: + if s.name == provider_key and s.is_oauth: + spec = s + break + if not spec: + oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth] console.print(f"[red]Unknown OAuth provider: {provider}[/red]") - console.print("[yellow]Supported providers: openai-codex[/yellow]") + console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]") + raise typer.Exit(1) + + console.print(f"{__logo__} OAuth Login - {spec.display_name}\n") + + if spec.name == "openai_codex": + _login_openai_codex() + elif spec.name == "github_copilot": + _login_github_copilot() + + +def _login_openai_codex() -> None: + """Authenticate with OpenAI Codex via oauth_cli_kit.""" + try: + from oauth_cli_kit import get_token, login_oauth_interactive + token = None + try: + token = get_token() + except Exception: + token = None + if not (token and token.access): + console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") + console.print("A browser window may open for you to authenticate.\n") + token = login_oauth_interactive( + print_fn=lambda s: console.print(s), + prompt_fn=lambda s: typer.prompt(s), + ) + if not (token and token.access): + console.print("[red]✗ Authentication failed[/red]") + raise typer.Exit(1) + console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") + console.print(f"[dim]Account ID: {token.account_id}[/dim]") + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Authentication error: {e}[/red]") + raise typer.Exit(1) + + +def _login_github_copilot() -> None: + """Authenticate with GitHub Copilot via LiteLLM's device flow. + + LiteLLM handles the full OAuth device flow (device code → poll → token + storage) internally when a github_copilot/ model is first called. + We trigger that flow by sending a minimal completion request. + """ + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]") + console.print("You will be prompted to visit a URL and enter a device code.\n") + + async def _trigger_device_flow() -> None: + from litellm import acompletion + await acompletion( + model="github_copilot/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1, + ) + + try: + asyncio.run(_trigger_device_flow()) + console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]") + except Exception as e: + error_msg = str(e) + # A successful device flow still returns a valid response; + # any exception here means the flow genuinely failed. + console.print(f"[red]Authentication error: {error_msg}[/red]") + console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]") raise typer.Exit(1) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 43dfbb5..8cc4e35 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -38,9 +38,6 @@ class LiteLLMProvider(LLMProvider): # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) - # Detect GitHub Copilot (uses OAuth device flow, no API key) - self.is_github_copilot = "github_copilot" in default_model - # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) @@ -79,10 +76,6 @@ class LiteLLMProvider(LLMProvider): def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" - # GitHub Copilot models pass through directly - if self.is_github_copilot: - return model - if self._gateway: # Gateway mode: apply gateway prefix, skip provider-specific prefixes prefix = self._gateway.litellm_prefix From d54831a35f25c09450054a451fef3f6a4cda7941 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 03:09:09 +0000 Subject: [PATCH 3/3] feat: add github copilot oauth login and improve provider status display --- README.md | 2 +- nanobot/cli/commands.py | 96 +++++++++++++++++----------------------- nanobot/config/schema.py | 4 +- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 30210a7..03789de 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,7 @@ Config file: `~/.nanobot/config.json` | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | — | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | -| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription | +| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
OpenAI Codex (OAuth) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5efc297..8e17139 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -922,48 +922,50 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") +_LOGIN_HANDLERS: dict[str, callable] = {} + + +def _register_login(name: str): + def decorator(fn): + _LOGIN_HANDLERS[name] = fn + return fn + return decorator + + @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"), + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), ): """Authenticate with an OAuth provider.""" from nanobot.providers.registry import PROVIDERS - # Normalize: "github-copilot" → "github_copilot" - provider_key = provider.replace("-", "_") - - # Validate against the registry — only OAuth providers support login - spec = None - for s in PROVIDERS: - if s.name == provider_key and s.is_oauth: - spec = s - break + key = provider.replace("-", "_") + spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None) if not spec: - oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth] - console.print(f"[red]Unknown OAuth provider: {provider}[/red]") - console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]") + names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) + console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") raise typer.Exit(1) - console.print(f"{__logo__} OAuth Login - {spec.display_name}\n") + handler = _LOGIN_HANDLERS.get(spec.name) + if not handler: + console.print(f"[red]Login not implemented for {spec.label}[/red]") + raise typer.Exit(1) - if spec.name == "openai_codex": - _login_openai_codex() - elif spec.name == "github_copilot": - _login_github_copilot() + console.print(f"{__logo__} OAuth Login - {spec.label}\n") + handler() +@_register_login("openai_codex") def _login_openai_codex() -> None: - """Authenticate with OpenAI Codex via oauth_cli_kit.""" try: from oauth_cli_kit import get_token, login_oauth_interactive token = None try: token = get_token() except Exception: - token = None + pass if not (token and token.access): - console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") - console.print("A browser window may open for you to authenticate.\n") + console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") token = login_oauth_interactive( print_fn=lambda s: console.print(s), prompt_fn=lambda s: typer.prompt(s), @@ -971,47 +973,29 @@ def _login_openai_codex() -> None: if not (token and token.access): console.print("[red]✗ Authentication failed[/red]") raise typer.Exit(1) - console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") + console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]") except ImportError: console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") raise typer.Exit(1) + + +@_register_login("github_copilot") +def _login_github_copilot() -> None: + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") + + async def _trigger(): + from litellm import acompletion + await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1) + + try: + asyncio.run(_trigger()) + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") except Exception as e: console.print(f"[red]Authentication error: {e}[/red]") raise typer.Exit(1) -def _login_github_copilot() -> None: - """Authenticate with GitHub Copilot via LiteLLM's device flow. - - LiteLLM handles the full OAuth device flow (device code → poll → token - storage) internally when a github_copilot/ model is first called. - We trigger that flow by sending a minimal completion request. - """ - import asyncio - - console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]") - console.print("You will be prompted to visit a URL and enter a device code.\n") - - async def _trigger_device_flow() -> None: - from litellm import acompletion - await acompletion( - model="github_copilot/gpt-4o", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - ) - - try: - asyncio.run(_trigger_device_flow()) - console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]") - except Exception as e: - error_msg = str(e) - # A successful device flow still returns a valid response; - # any exception here means the flow genuinely failed. - console.print(f"[red]Authentication error: {error_msg}[/red]") - console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]") - raise typer.Exit(1) - - if __name__ == "__main__": app() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe909ef..3cacbde 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -300,9 +300,7 @@ class Config(BaseSettings): if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) - if p is None: - continue - if p.api_key: + if p and p.api_key: return p, spec.name return None, None