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
3 changes: 2 additions & 1 deletion src/fast_agent/cli/runtime/model_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,8 @@ def activate_model_picker_provider(action: ProviderActivation) -> bool:
from fast_agent.ui import console

handler = get_oauth_provider(action.provider.config_name)
if handler.status().get("present"):
status = handler.status()
if status.get("present") and not status.get("expired"):
return True

typer.echo(f"Starting {handler.display_name} OAuth login...", err=True)
Expand Down
8 changes: 8 additions & 0 deletions src/fast_agent/history/process_poll_folding.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ def _process_status(exchange: PollExchange) -> str | None:
return status if isinstance(status, str) else None


def _has_resource_observation(exchange: PollExchange) -> bool:
return isinstance(exchange.metadata.get("resource_observation"), str)


def _output_line_count(exchange: PollExchange) -> int | None:
return _non_negative_int(exchange.metadata.get("output_line_count"))

Expand Down Expand Up @@ -845,6 +849,8 @@ def fold_managed_process_poll_history(
process_status = _process_status(current)
if process_status not in _FOLDABLE_PROCESS_STATUSES:
return None
if _has_resource_observation(current):
return None

reverse_exchanges = [current]
cursor = len(history) - 2
Expand All @@ -854,6 +860,8 @@ def fold_managed_process_poll_history(
exchange = _exchange(request, result, request_index=cursor - 1)
if exchange is None or exchange.process_id != current.process_id:
break
if _has_resource_observation(exchange):
break
reverse_exchanges.append(exchange)
cursor -= 2

Expand Down
12 changes: 6 additions & 6 deletions src/fast_agent/llm/provider/openai/codex_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,16 @@ def _load_codex_tokens_with_source() -> tuple[CodexOAuthTokens | None, str | Non
stored = load_oauth_credential("codex")
return (_tokens_from_credential(stored.credential), stored.source) if stored else (None, None)

# Codex CLI credentials are external and read-only. Prefer them before touching
# the OS keyring so an existing auth.json is sufficient on its own.
tokens = _load_codex_cli_tokens()
if tokens:
return tokens, "auth.json"

stored = load_oauth_credential("codex")
if stored:
return _tokens_from_credential(stored.credential), stored.source

# Codex CLI credentials are an external, read-only fallback. Fast-agent-owned
# credentials take precedence so refreshed tokens can persist without modifying
# the CLI's auth.json.
tokens = _load_codex_cli_tokens()
if tokens:
return tokens, "auth.json"
_warn_about_legacy_codex_keyring_credentials()
return None, None

Expand Down
24 changes: 22 additions & 2 deletions src/fast_agent/llm/provider/openai/xai_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class XaiDeviceCode:
interval: float


class _InvalidGrantError(ProviderKeyError):
pass


def _oauth_error(action: str, response: httpx.Response) -> ProviderKeyError:
try:
payload = response.json()
Expand All @@ -54,6 +58,15 @@ def _oauth_error(action: str, response: httpx.Response) -> ProviderKeyError:
)


def _oauth_error_code(response: httpx.Response) -> str | None:
try:
payload = response.json()
except ValueError:
return None
error = payload.get("error") if isinstance(payload, dict) else None
return error if isinstance(error, str) else None


def _required_string(payload: dict[str, Any], field: str) -> str:
value = payload.get(field)
if not isinstance(value, str) or not value:
Expand Down Expand Up @@ -190,7 +203,10 @@ def refresh_xai_credential(
},
)
if not response.is_success:
raise _oauth_error("token refresh", response)
error = _oauth_error("token refresh", response)
if _oauth_error_code(response) == "invalid_grant":
raise _InvalidGrantError(error.message, error.details)
raise error
payload = response.json()
if not isinstance(payload, dict):
raise ProviderKeyError("Invalid xAI OAuth response", "Expected a JSON object.")
Expand Down Expand Up @@ -243,7 +259,11 @@ def get_xai_access_token(
credential = current.credential
expired = credential.expires_at is not None and time.time() >= credential.expires_at
if force_refresh or expired:
credential = refresh_xai_credential(credential, client=client)
try:
credential = refresh_xai_credential(credential, client=client)
except _InvalidGrantError:
delete_oauth_credential(XAI_PROVIDER_ID)
return None
save_oauth_credential(
XAI_PROVIDER_ID,
credential,
Expand Down
Loading
Loading