Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/_generated/tui_runtime_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
| `logger.tool_display.stream_edit_previews` | `LOGGER__TOOL_DISPLAY__STREAM_EDIT_PREVIEWS` | `Literal['off', 'primary', 'all']` | `primary` | Stream apply_patch/edit_file previews for the primary agent or all agents. |
| `logger.tool_display.aggregate_parallel` | `LOGGER__TOOL_DISPLAY__AGGREGATE_PARALLEL` | `bool` | `True` | Aggregate safe parallel generic calls when argument bodies are disabled. |
| `logger.terminal_images.enabled` | `LOGGER__TERMINAL_IMAGES__ENABLED` | `bool` | `True` | Render image content in capable terminals. |
| `logger.terminal_images.backend` | `LOGGER__TERMINAL_IMAGES__BACKEND` | `Literal['auto', 'textual-image', 'kitty', 'sixel', 'halfcell', 'unicode', 'none']` | `auto` | Terminal image backend; automatic Sixel rendering is fitted to the viewport. |
| `logger.terminal_images.backend` | `LOGGER__TERMINAL_IMAGES__BACKEND` | `Literal['auto', 'textual-image', 'kitty', 'sixel', 'halfcell', 'unicode', 'none']` | `auto` | Auto uses reported terminal support, including Kitty in Herdr; use halfcell to opt out. Automatic Sixel rendering is fitted to the viewport. |
| `logger.terminal_images.width` | `LOGGER__TERMINAL_IMAGES__WIDTH` | `TerminalImageSize` | `80%` | Image render width. |
| `logger.terminal_images.height` | `LOGGER__TERMINAL_IMAGES__HEIGHT` | `TerminalImageSize` | `auto` | Image render height. |
| `shell_execution.tool_profile` | `SHELL_EXECUTION__TOOL_PROFILE` | `ShellToolProfile` | `auto` | Model-specific shell/process contract. |
Expand Down
16 changes: 14 additions & 2 deletions docs/docs/guides/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,20 @@ If you want to use the Codex OAuth models directly, authenticate once first:
fast-agent auth provider login codex
```

This stores the token in your OS keyring. After that you can use Codex OAuth
model aliases such as:
Login uses device auth by default: open the displayed URL in a browser and enter
the one-time code. No local callback server is required. Only enter a code from
a login you started yourself.

If device auth is unavailable for your account, use the existing browser callback
flow instead:

```bash
fast-agent auth provider login codex --method browser
```

Device login failures do not automatically switch to browser login. Credentials
are stored in your OS keyring, with a secure file fallback. After that you can use
Codex OAuth model aliases such as:

- `codexplan` — GPT-6-Astra with medium reasoning

Expand Down
30 changes: 30 additions & 0 deletions docs/docs/guides/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ of the stdin interpreter. This includes direct interpreters such as
TypeScript executed with `pnpm exec tsx -` (including `pnpm -C <dir> exec`).
Highlighting is applied while the heredoc is still streaming.

## Terminal images

Image rendering automatically uses reported terminal graphics support, including
Kitty graphics inside Herdr. Herdr can report Kitty support even when the outer
terminal cannot display it. For Herdr inside Foot or Windows Terminal, select
half-cell rendering:

```yaml
logger:
terminal_images:
backend: halfcell
```

Alternatively, select it for a terminal environment:

```bash
export LOGGER__TERMINAL_IMAGES__BACKEND=halfcell
```

In PowerShell:

```powershell
$env:LOGGER__TERMINAL_IMAGES__BACKEND = "halfcell"
```

Explicit configuration takes precedence over this environment variable; leave
`backend` unset in YAML to select it via the environment. Restart fast-agent
after changing these settings. With Herdr inside Ghostty or Kitty, leave the
backend at its automatic default or explicitly select `kitty`.

## Shell Integration

You can run a shell command with `!` - for example `! git status`. When the active agent uses a local shell environment, commands run attached to your terminal, so interactive programs such as `! nano` work as expected. If the active agent uses a remote or sandbox environment, `!` runs in that environment; use `!!` to force a local shell command instead.
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/models/providers/openai.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,12 @@ once, then use a Codex OAuth model alias such as `astra` (GPT-6-Astra), `codexpl
**Quick Start:**

```bash
# Start OAuth login (stores tokens in your OS keyring)
# Start device auth (stores tokens in your OS keyring, with a secure file fallback)
fast-agent auth provider login codex

# Alternatively, use browser callback login if device auth is unavailable
fast-agent auth provider login codex --method browser

# Use GPT-6-Astra through the Codex subscription
fast-agent --model astra

Expand Down
3 changes: 2 additions & 1 deletion docs/generate_reference_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,8 @@ def generate_tui_runtime_reference() -> str:
),
"logger.terminal_images.enabled": "Render image content in capable terminals.",
"logger.terminal_images.backend": (
"Terminal image backend; automatic Sixel rendering is fitted to the viewport."
"Auto uses reported terminal support, including Kitty in Herdr; "
"use halfcell to opt out. Automatic Sixel rendering is fitted to the viewport."
),
"logger.terminal_images.width": "Image render width.",
"logger.terminal_images.height": "Image render height.",
Expand Down
5 changes: 5 additions & 0 deletions examples/setup/fast-agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ logger:
show_successful_file_reads: false
stream_edit_previews: primary # off | primary | all
aggregate_parallel: true
# Images auto-detect reported terminal support, including Kitty in Herdr.
# For Herdr inside Foot/Windows Terminal, use halfcell here or set
# LOGGER__TERMINAL_IMAGES__BACKEND=halfcell (leave backend unset for env selection).
# terminal_images:
# backend: halfcell
# Streaming renderer for assistant responses: "markdown", "plain", or "none"
streaming: markdown
# Render markdown code fences with Rich Syntax instead of markdown fence blocks
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "fast-agent-mcp"
version = "0.10.21"
version = "0.10.22"
description = "Code, Build and Evaluate agents - excellent Model and Skills/MCP/ACP/A2A Support"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
5 changes: 5 additions & 0 deletions src/fast_agent/auth/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class OAuthProvider:
access_token: Callable[[], str | None]
status: Callable[[], dict[str, object]]
logout: Callable[[], bool]
browser_login: Callable[[], OAuthCredential] | None = None


def _xai_provider() -> OAuthProvider:
Expand Down Expand Up @@ -68,6 +69,9 @@ def _codex_provider() -> OAuthProvider:
def login() -> OAuthCredential:
return _codex_credential(login_codex_oauth())

def browser_login() -> OAuthCredential:
return _codex_credential(login_codex_oauth(method="browser"))

def credential() -> OAuthCredential | None:
tokens = load_codex_tokens()
return _codex_credential(tokens) if tokens else None
Expand All @@ -89,6 +93,7 @@ def _codex_credential(tokens: CodexOAuthTokens) -> OAuthCredential:
access_token=get_codex_access_token,
status=get_codex_token_status,
logout=clear_codex_tokens,
browser_login=browser_login,
)


Expand Down
23 changes: 21 additions & 2 deletions src/fast_agent/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import sys
from dataclasses import asdict, dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from urllib.parse import urlparse
Expand Down Expand Up @@ -488,17 +489,35 @@ def provider_show(
_print_provider_views([view])


class ProviderLoginMethod(str, Enum):
DEVICE = "device"
BROWSER = "browser"


@provider_app.command("login")
def provider_login(
provider: str = typer.Argument(..., help="Provider name: xai or codex"),
method: ProviderLoginMethod = typer.Option(
ProviderLoginMethod.DEVICE,
"--method",
help="Login method. Browser login is available for Codex accounts without device auth.",
),
) -> None:
"""Authenticate with a model provider."""
"""Authenticate with a model provider using device auth by default."""
from fast_agent.auth.providers import get_oauth_provider
from fast_agent.core.exceptions import ProviderKeyError, format_fast_agent_error

try:
handler = get_oauth_provider(provider)
handler.login()
login = handler.login
if method == ProviderLoginMethod.BROWSER:
if handler.browser_login is None:
raise ProviderKeyError(
"Unsupported login method",
f"{handler.display_name} does not support browser login. Use --method device.",
)
login = handler.browser_login
login()
typer.echo(f"{handler.display_name} OAuth login complete.")
except ProviderKeyError as exc:
typer.echo(format_fast_agent_error(exc), err=True)
Expand Down
5 changes: 4 additions & 1 deletion src/fast_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1733,7 +1733,10 @@ class TerminalImageSettings(BaseModel):
"unicode",
"none",
] = "auto"
"""Terminal image backend; automatic Sixel rendering is fitted to the viewport."""
"""Auto uses reported terminal support, including Kitty in Herdr; use halfcell to opt out.

Automatic Sixel rendering is fitted to the viewport.
"""

width: TerminalImageSize = "80%"
"""Image render width: cells, percentage (e.g. '80%'), 'auto', or null."""
Expand Down
118 changes: 110 additions & 8 deletions src/fast_agent/llm/provider/openai/codex_oauth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Codex OAuth helpers for ChatGPT/Codex tokens.

Implements the OAuth PKCE flow used by the Codex CLI, including keyring
storage and refresh. Access tokens are used as API keys when calling the
Codex responses endpoint.
Implements device authorization and browser OAuth PKCE login, including
keyring storage and refresh. Access tokens are used as API keys when calling
the Codex responses endpoint.
"""

from __future__ import annotations
Expand All @@ -17,11 +17,11 @@
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from typing import Annotated, Any, Literal
from urllib.parse import parse_qs, urlencode, urlparse

import httpx
from pydantic import BaseModel
from pydantic import AliasChoices, BaseModel, Field, ValidationError

from fast_agent.auth.credentials import (
OAuthCredential,
Expand Down Expand Up @@ -393,13 +393,15 @@ def build_authorization_url(code_challenge: str, state: str) -> str:
return f"{CODEX_AUTHORIZE_URL}?{urlencode(params)}"


def exchange_code_for_tokens(code: str, code_verifier: str) -> CodexOAuthTokens:
def exchange_code_for_tokens(
code: str, code_verifier: str, *, redirect_uri: str = CODEX_REDIRECT_URI
) -> CodexOAuthTokens:
payload = {
"grant_type": "authorization_code",
"client_id": CODEX_CLIENT_ID,
"code": code,
"code_verifier": code_verifier,
"redirect_uri": CODEX_REDIRECT_URI,
"redirect_uri": redirect_uri,
}
return _token_request(payload)

Expand Down Expand Up @@ -465,7 +467,7 @@ def parse_chatgpt_account_id(access_token: str) -> str | None:
return None


def login_codex_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens:
def login_codex_browser_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens:
verifier = _pkce_verifier()
challenge = _pkce_challenge(verifier)
state = secrets.token_urlsafe(16)
Expand Down Expand Up @@ -519,3 +521,103 @@ def login_codex_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens:
tokens = exchange_code_for_tokens(code, verifier)
save_codex_tokens(tokens)
return tokens


class _DeviceUserCode(BaseModel):
device_auth_id: Annotated[str, Field(min_length=1)]
user_code: Annotated[
str, Field(min_length=1, validation_alias=AliasChoices("user_code", "usercode"))
]
interval: Annotated[str, Field(pattern=r"^\s*[0-9]+\s*$")] = "5"


class _DeviceAuthorization(BaseModel):
authorization_code: Annotated[str, Field(min_length=1)]
code_verifier: Annotated[str, Field(min_length=1)]


def _device_login_error(detail: str) -> ProviderKeyError:
return ProviderKeyError(
"Codex device login failed",
f"{detail} Retry login or use `fast-agent auth provider login codex --method browser`.",
)


def login_codex_device_oauth(timeout_seconds: int = 900) -> CodexOAuthTokens:
"""Authorize with a user-entered device code; never fall back automatically."""
deadline = time.monotonic() + min(timeout_seconds, 900)

def remaining() -> float:
seconds = deadline - time.monotonic()
if seconds <= 0:
raise _device_login_error("Device authorization timed out.")
return seconds

try:
with httpx.Client(timeout=30.0) as client:
response = client.post(
"https://auth.openai.com/api/accounts/deviceauth/usercode",
json={"client_id": CODEX_CLIENT_ID},
timeout=min(30.0, remaining()),
)
if not response.is_success:
raise _device_login_error(
f"Unable to request a device code (HTTP {response.status_code}). "
"Ensure device code login is enabled for your account."
)
device = _DeviceUserCode.model_validate_json(response.content)
interval = max(1, int(device.interval))
console.ensure_blocking_console()
console.console.print(
"Open https://auth.openai.com/codex/device and enter this one-time code:",
markup=False,
)
console.console.print(device.user_code, markup=False)
console.console.print(
"Continue only if you started this login. If a website or another person "
"gave you this code, cancel.",
markup=False,
)
while True:
response = client.post(
"https://auth.openai.com/api/accounts/deviceauth/token",
json={"device_auth_id": device.device_auth_id, "user_code": device.user_code},
timeout=min(30.0, remaining()),
)
remaining()
if response.is_success:
authorization = _DeviceAuthorization.model_validate_json(response.content)
break
if response.status_code not in (403, 404):
raise _device_login_error(
f"Device authorization failed (HTTP {response.status_code})."
)
time.sleep(min(interval, remaining()))
try:
tokens = exchange_code_for_tokens(
authorization.authorization_code,
authorization.code_verifier,
redirect_uri="https://auth.openai.com/deviceauth/callback",
)
except ProviderKeyError:
raise _device_login_error("Device authorization token exchange failed.") from None
except (httpx.HTTPError, ValidationError, ValueError):
# Do not expose response bodies, validation inputs, or token exchange errors.
raise _device_login_error(
"Device authorization could not be completed (request failed, invalid response, "
"or authorization timed out)."
) from None
save_codex_tokens(tokens)
return tokens


def login_codex_oauth(
timeout_seconds: int | None = None, *, method: Literal["device", "browser"] = "device"
) -> CodexOAuthTokens:
if method == "device":
return login_codex_device_oauth(900 if timeout_seconds is None else timeout_seconds)
if method == "browser":
return login_codex_browser_oauth(300 if timeout_seconds is None else timeout_seconds)
raise ProviderKeyError(
"Codex OAuth login failed", "Unknown login method. Use device or browser."
)
4 changes: 2 additions & 2 deletions src/fast_agent/ui/prompt/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,7 @@ def _initialize_prompt_input_state(
global in_multiline_mode, available_agents

in_multiline_mode = multiline
if available_agent_names:
available_agents = set(available_agent_names)
available_agents = set(available_agent_names or [agent_name])
if agent_provider is not None:
with suppress(Exception):
available_agents = set(agent_provider.visible_agent_names(force_include=agent_name))
Expand Down Expand Up @@ -330,6 +329,7 @@ def get_toolbar() -> HTML:
current_input_text = ""
result = render_input_toolbar(
agent_name=agent_name,
show_agent_name=len(available_agents) > 1,
toolbar_color=toolbar_color,
agent_provider=agent_provider,
multiline_mode=in_multiline_mode,
Expand Down
7 changes: 4 additions & 3 deletions src/fast_agent/ui/prompt/status_bar/agent_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from fast_agent.core.agent_capabilities import AgentCapabilityMode
from fast_agent.ui.binary_indicator import (
TOOLBAR_BINARY_DISABLED_COLOR,
TOOLBAR_BINARY_ENABLED_COLOR,
render_glyph_indicator,
)
Expand All @@ -18,10 +17,12 @@ def render_agent_capability_indicator(mode: AgentCapabilityMode) -> str:
harness = mode in {AgentCapabilityMode.HARNESS_ONLY, AgentCapabilityMode.ORCHESTRATE}
subagent_indicator = render_glyph_indicator(
glyph=SUBAGENT_GLYPH,
color=TOOLBAR_BINARY_ENABLED_COLOR if subagents else TOOLBAR_BINARY_DISABLED_COLOR,
color="ansiblack" if subagents else "ansiwhite",
foreground=TOOLBAR_BINARY_ENABLED_COLOR if subagents else "ansiblack",
)
harness_indicator = render_glyph_indicator(
glyph=f"{HARNESS_GLYPH} ",
color=TOOLBAR_BINARY_ENABLED_COLOR if harness else TOOLBAR_BINARY_DISABLED_COLOR,
color="ansiblack" if harness else "ansiwhite",
foreground=TOOLBAR_BINARY_ENABLED_COLOR if harness else "ansiblack",
)
return f"{subagent_indicator}{harness_indicator}"
Loading
Loading