From 90b05f3881cc3ffd4c6997c3c730e691d553f176 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Thu, 2 Jul 2026 23:33:17 +0100 Subject: [PATCH 01/10] Add GitHub Copilot provider --- src/tau_ai/anthropic.py | 5 +- src/tau_ai/env.py | 5 + src/tau_coding/cli.py | 4 +- src/tau_coding/oauth.py | 218 +++++++++++ src/tau_coding/provider_catalog.py | 52 +++ src/tau_coding/provider_config.py | 305 +++++++++++++++- src/tau_coding/provider_runtime.py | 106 +++++- src/tau_coding/tui/app.py | 230 ++++++++---- tests/test_oauth.py | 46 +++ tests/test_provider_config.py | 195 +++++++++- tests/test_tau_ai.py | 555 ++++++++++++++++++++++++++--- tests/test_tui_app.py | 13 +- 12 files changed, 1598 insertions(+), 136 deletions(-) diff --git a/src/tau_ai/anthropic.py b/src/tau_ai/anthropic.py index edb0f0e13..53dfe021f 100644 --- a/src/tau_ai/anthropic.py +++ b/src/tau_ai/anthropic.py @@ -71,8 +71,11 @@ async def iterator() -> AsyncIterator[ProviderEvent]: **(dict(self._config.headers or {})), "anthropic-version": ANTHROPIC_VERSION, "content-type": "application/json", - "x-api-key": self._config.api_key, } + if self._config.auth_header.lower() == "authorization": + headers["Authorization"] = f"Bearer {self._config.api_key}" + else: + headers["x-api-key"] = self._config.api_key url = f"{self._config.base_url.rstrip('/')}/messages" attempt = 0 diff --git a/src/tau_ai/env.py b/src/tau_ai/env.py index 0bba65fe0..b54f897d1 100644 --- a/src/tau_ai/env.py +++ b/src/tau_ai/env.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass from os import environ +from typing import Literal DEFAULT_OPENAI_COMPATIBLE_BASE_URL = "https://api.openai.com/v1" DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1" @@ -12,6 +13,8 @@ DEFAULT_OPENAI_COMPATIBLE_MAX_RETRIES = 2 DEFAULT_OPENAI_COMPATIBLE_MAX_RETRY_DELAY_SECONDS = 1.0 +AnthropicThinkingType = Literal["adaptive", "disabled"] + @dataclass(frozen=True, slots=True) class OpenAICompatibleConfig: @@ -38,6 +41,8 @@ class AnthropicConfig: max_retries: int = DEFAULT_OPENAI_COMPATIBLE_MAX_RETRIES max_retry_delay_seconds: float = DEFAULT_OPENAI_COMPATIBLE_MAX_RETRY_DELAY_SECONDS thinking_budget_tokens: int | None = None + thinking_type: AnthropicThinkingType | None = None + auth_header: str = "x-api-key" def openai_compatible_config_from_env( diff --git a/src/tau_coding/cli.py b/src/tau_coding/cli.py index 5a76c9b7d..cd8576bca 100644 --- a/src/tau_coding/cli.py +++ b/src/tau_coding/cli.py @@ -432,11 +432,11 @@ def _provider_credential_status( credential_reader: CredentialReader | None, ) -> str: if provider.credential_name and credential_reader is not None: - if provider_kind(provider) == "openai-codex": + if provider_kind(provider) == "openai-codex" or provider.name == "github-copilot": get_oauth = getattr(credential_reader, "get_oauth", None) if get_oauth is not None and get_oauth(provider.credential_name) is not None: return f"stored:{provider.credential_name}" - elif credential_reader.get(provider.credential_name): + if credential_reader.get(provider.credential_name): return f"stored:{provider.credential_name}" if environ.get(provider.api_key_env): return f"env:{provider.api_key_env}" diff --git a/src/tau_coding/oauth.py b/src/tau_coding/oauth.py index 6d3827a06..07d831fcb 100644 --- a/src/tau_coding/oauth.py +++ b/src/tau_coding/oauth.py @@ -21,6 +21,16 @@ from tau_coding.credentials import OAuthCredential +GITHUB_COPILOT_OAUTH_PROVIDER = "github-copilot" +GITHUB_COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98" +GITHUB_COPILOT_API_VERSION = "2026-06-01" +GITHUB_COPILOT_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", +} + OPENAI_CODEX_OAUTH_PROVIDER = "openai-codex" OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" OPENAI_CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" @@ -501,3 +511,211 @@ def _oauth_html(message: str) -> str: .replace('"', """) ) return f'Tau OAuth

{escaped}

' + + +@dataclass(frozen=True, slots=True) +class GitHubCopilotDeviceFlow: + """GitHub device-code login state for Copilot.""" + + device_code: str + user_code: str + verification_uri: str + interval: int + expires_in: int + + +async def login_github_copilot( + *, + on_auth: AuthCallback, + on_prompt: PromptCallback | None = None, + on_progress: ProgressCallback | None = None, + client: httpx.AsyncClient | None = None, +) -> OAuthCredential: + """Run GitHub Copilot device OAuth and return refreshable credentials. + + The stored refresh value is the GitHub OAuth token. The stored access value + is the short-lived Copilot API token, refreshed on demand via GitHub's + ``/copilot_internal/v2/token`` endpoint. + """ + enterprise_domain = "" + if on_prompt is not None: + raw_domain = await on_prompt( + OAuthPrompt( + message="GitHub Enterprise URL/domain (blank for github.com):", + placeholder="company.ghe.com", + ) + ) + enterprise_domain = normalize_github_domain(raw_domain) + domain = enterprise_domain or "github.com" + + owns_client = client is None + http_client = client or httpx.AsyncClient(timeout=30) + try: + device = await start_github_copilot_device_flow(domain, client=http_client) + on_auth( + OAuthAuthInfo( + url=device.verification_uri, + instructions=f"Enter code: {device.user_code}", + ) + ) + github_token = await poll_github_copilot_device_flow( + domain, + device, + client=http_client, + ) + on_progress and on_progress("Exchanging GitHub token for Copilot token...") + credential = await refresh_github_copilot_token( + github_token, + enterprise_domain=enterprise_domain, + client=http_client, + ) + on_progress and on_progress("Fetching Copilot model availability...") + return credential + finally: + if owns_client: + await http_client.aclose() + + +async def start_github_copilot_device_flow( + domain: str = "github.com", + *, + client: httpx.AsyncClient | None = None, +) -> GitHubCopilotDeviceFlow: + """Start GitHub's device-code OAuth flow for Copilot.""" + owns_client = client is None + http_client = client or httpx.AsyncClient(timeout=30) + try: + response = await http_client.post( + f"https://{domain}/login/device/code", + data={"client_id": GITHUB_COPILOT_CLIENT_ID, "scope": "read:user"}, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": GITHUB_COPILOT_HEADERS["User-Agent"], + }, + ) + response.raise_for_status() + raw = response.json() + return GitHubCopilotDeviceFlow( + device_code=_required_string(raw, "device_code", action="device flow"), + user_code=_required_string(raw, "user_code", action="device flow"), + verification_uri=_required_string(raw, "verification_uri", action="device flow"), + interval=int(raw.get("interval") or 5), + expires_in=int(raw.get("expires_in") or 900), + ) + finally: + if owns_client: + await http_client.aclose() + + +async def poll_github_copilot_device_flow( + domain: str, + device: GitHubCopilotDeviceFlow, + *, + client: httpx.AsyncClient | None = None, +) -> str: + """Poll GitHub's device-code endpoint until the GitHub token is available.""" + owns_client = client is None + http_client = client or httpx.AsyncClient(timeout=30) + interval = max(device.interval, 5) + deadline = time.monotonic() + device.expires_in + try: + while time.monotonic() < deadline: + await asyncio.sleep(interval) + response = await http_client.post( + f"https://{domain}/login/oauth/access_token", + data={ + "client_id": GITHUB_COPILOT_CLIENT_ID, + "device_code": device.device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": GITHUB_COPILOT_HEADERS["User-Agent"], + }, + ) + response.raise_for_status() + raw = response.json() + token = raw.get("access_token") + if isinstance(token, str) and token.strip(): + return token.strip() + error = raw.get("error") + if error in {"authorization_pending", None}: + continue + if error == "slow_down": + interval += 5 + continue + description = raw.get("error_description") + suffix = f": {description}" if isinstance(description, str) else "" + raise OAuthError(f"GitHub device flow failed: {error}{suffix}") + finally: + if owns_client: + await http_client.aclose() + raise OAuthError("GitHub device flow timed out") + + +async def refresh_github_copilot_token( + github_token: str, + *, + enterprise_domain: str = "", + client: httpx.AsyncClient | None = None, +) -> OAuthCredential: + """Exchange a GitHub OAuth token for a short-lived Copilot API token.""" + domain = enterprise_domain or "github.com" + owns_client = client is None + http_client = client or httpx.AsyncClient(timeout=30) + try: + response = await http_client.get( + f"https://api.{domain}/copilot_internal/v2/token", + headers={ + **GITHUB_COPILOT_HEADERS, + "Accept": "application/json", + "Authorization": f"Bearer {github_token}", + }, + ) + response.raise_for_status() + raw = response.json() + access = _required_string(raw, "token", action="copilot token") + expires_at = raw.get("expires_at") + if not isinstance(expires_at, int | float) or isinstance(expires_at, bool): + raise OAuthError("Missing Copilot token expiry") + return OAuthCredential( + access=access, + refresh=github_token, + expires=int(expires_at * 1000) - 5 * 60 * 1000, + account_id=enterprise_domain or "github.com", + ) + finally: + if owns_client: + await http_client.aclose() + + +def github_copilot_base_url(token: str, enterprise_domain: str = "") -> str: + """Return the Copilot API base URL for a token or enterprise domain.""" + marker = "proxy-ep=" + if marker in token: + host = token.split(marker, 1)[1].split(";", 1)[0] + if host: + return "https://" + host.replace("proxy.", "api.", 1) + if enterprise_domain and enterprise_domain != "github.com": + return f"https://copilot-api.{enterprise_domain}" + return "https://api.individual.githubcopilot.com" + + +def normalize_github_domain(value: str | None) -> str: + """Normalize a user-entered GitHub Enterprise URL/domain.""" + stripped = (value or "").strip() + if not stripped: + return "" + if "://" not in stripped: + stripped = "https://" + stripped + parsed = urlparse(stripped) + return parsed.hostname or "" + + +def _required_string(raw: dict[str, Any], field: str, *, action: str) -> str: + value = raw.get(field) + if not isinstance(value, str) or not value.strip(): + raise OAuthError(f"Missing {field} in GitHub Copilot {action} response") + return value.strip() diff --git a/src/tau_coding/provider_catalog.py b/src/tau_coding/provider_catalog.py index d4b61afdd..d0934ac91 100644 --- a/src/tau_coding/provider_catalog.py +++ b/src/tau_coding/provider_catalog.py @@ -145,6 +145,58 @@ class ProviderCatalogEntry: thinking_default="medium", thinking_parameter="anthropic.thinking", ), + ProviderCatalogEntry( + name="github-copilot", + display_name="GitHub Copilot", + kind="openai-compatible", + base_url="https://api.individual.githubcopilot.com", + api_key_env="GITHUB_COPILOT_TOKEN", + credential_name="github-copilot", + models=( + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.2", + "gpt-5-mini", + "claude-sonnet-4.6", + "claude-opus-4.8", + "claude-haiku-4.5", + "gemini-2.5-pro", + ), + default_model="gpt-5.4", + docs_url="https://docs.github.com/copilot", + context_windows={ + "gpt-5.5": 272_000, + "gpt-5.4": 272_000, + "gpt-5.4-mini": 400_000, + "gpt-5.3-codex": 400_000, + "gpt-5.2": 400_000, + "gpt-5-mini": 400_000, + "claude-sonnet-4.6": 1_000_000, + "claude-opus-4.8": 1_000_000, + "claude-haiku-4.5": 200_000, + "gemini-2.5-pro": 1_048_576, + }, + thinking_levels=("off", "low", "medium", "high", "xhigh"), + thinking_models=( + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.2", + "gpt-5-mini", + "claude-sonnet-4.6", + "claude-opus-4.8", + ), + thinking_default="medium", + thinking_parameter="reasoning_effort", + model_overrides={ + "claude-sonnet-4.6": ProviderModelOverride(kind="anthropic"), + "claude-opus-4.8": ProviderModelOverride(kind="anthropic"), + "claude-haiku-4.5": ProviderModelOverride(kind="anthropic"), + }, + ), ProviderCatalogEntry( name="openrouter", display_name="OpenRouter", diff --git a/src/tau_coding/provider_config.py b/src/tau_coding/provider_config.py index 526447a6c..8c2cfbeb8 100644 --- a/src/tau_coding/provider_config.py +++ b/src/tau_coding/provider_config.py @@ -11,6 +11,8 @@ from tempfile import NamedTemporaryFile from typing import Any, Protocol +import httpx + from tau_ai import ( DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL, @@ -19,13 +21,26 @@ DEFAULT_OPENAI_COMPATIBLE_TIMEOUT_SECONDS, AnthropicConfig, OpenAICompatibleConfig, + list_openai_compatible_models, ) -from tau_ai.env import DEFAULT_OPENAI_COMPATIBLE_BASE_URL +from tau_ai.env import DEFAULT_OPENAI_COMPATIBLE_BASE_URL, AnthropicThinkingType from tau_coding.credentials import FileCredentialStore, credentials_path +from tau_coding.oauth import ( + github_copilot_base_url, + oauth_credential_is_expired, + refresh_github_copilot_token, +) from tau_coding.paths import TauPaths -from tau_coding.provider_catalog import BUILTIN_PROVIDER_CATALOG, ProviderKind +from tau_coding.provider_catalog import ( + BUILTIN_PROVIDER_CATALOG, + ProviderKind, + ThinkingMode, + builtin_provider_entry, + catalog_model_override, +) from tau_coding.thinking import ( DEFAULT_THINKING_LEVEL, + THINKING_LEVELS, ThinkingLevel, ThinkingParameter, anthropic_thinking_budget_for_level, @@ -67,6 +82,7 @@ class OpenAICompatibleProviderConfig: thinking_models: tuple[str, ...] = () thinking_default: ThinkingLevel | None = None thinking_parameter: ThinkingParameter | None = None + dynamic_models: bool = False def __post_init__(self) -> None: _validate_provider_numbers( @@ -103,6 +119,7 @@ def to_json(self) -> dict[str, Any]: "thinking_models": list(self.thinking_models), "thinking_default": self.thinking_default, "thinking_parameter": self.thinking_parameter, + "dynamic_models": self.dynamic_models, } @@ -114,8 +131,8 @@ class AnthropicProviderConfig: base_url: str = DEFAULT_ANTHROPIC_BASE_URL api_key_env: str = "ANTHROPIC_API_KEY" credential_name: str | None = "anthropic" - models: tuple[str, ...] = ("claude-sonnet-4-6",) - default_model: str = "claude-sonnet-4-6" + models: tuple[str, ...] = ("claude-fable-5", "claude-sonnet-5", "claude-sonnet-4-6") + default_model: str = "claude-sonnet-5" context_windows: dict[str, int] = field(default_factory=dict) headers: dict[str, str] = field(default_factory=dict) timeout_seconds: float = DEFAULT_OPENAI_COMPATIBLE_TIMEOUT_SECONDS @@ -334,6 +351,7 @@ def provider_config_from_catalog_entry(name: str) -> ProviderConfig: thinking_models=entry.thinking_models, thinking_default=entry.thinking_default, thinking_parameter=entry.thinking_parameter, + dynamic_models=entry.dynamic_models, ) raise ProviderConfigError(f"Unknown built-in provider: {name}") @@ -491,6 +509,20 @@ def upsert_provider( return updated +def _replace_provider(settings: ProviderSettings, provider: ProviderConfig) -> ProviderSettings: + """Return settings with an exact provider replacement, without built-in model merging.""" + providers_by_name = {item.name: item for item in settings.providers} + providers_by_name[provider.name] = provider + providers = tuple(providers_by_name[name] for name in sorted(providers_by_name)) + updated = ProviderSettings( + default_provider=settings.default_provider, + providers=providers, + scoped_models=settings.scoped_models, + ) + updated.get_provider(settings.default_provider) + return updated + + def _with_builtin_catalog_models( settings: ProviderSettings, *, @@ -668,14 +700,60 @@ def provider_thinking_levels( model: str | None = None, ) -> tuple[ThinkingLevel, ...]: """Return thinking levels supported by a provider/model pair.""" + selected_model = model or provider.default_model + override = catalog_model_override(provider.name, selected_model) + if override is not None: + if override.always_thinking: + return () + if override.thinking_modes is not None: + return tuple(level for level in THINKING_LEVELS if level in override.thinking_modes) if provider.thinking_levels is None: return () - selected_model = model or provider.default_model if provider.thinking_models and selected_model not in provider.thinking_models: return () return provider.thinking_levels +def provider_thinking_is_always_on( + provider: ProviderConfig, + *, + model: str | None = None, +) -> bool: + """Return whether built-in metadata declares reasoning as always enabled.""" + selected_model = model or provider.default_model + override = catalog_model_override(provider.name, selected_model) + return override.always_thinking if override is not None else False + + +def provider_thinking_level_label( + provider: ProviderConfig, + level: str, + *, + model: str | None = None, +) -> str: + """Return the provider-facing display label for a canonical Tau level.""" + normalized = normalize_thinking_level(level) + mode = _thinking_mode(provider, model=model, level=normalized) + return mode.label if mode is not None and mode.label is not None else normalized + + +def provider_thinking_level_from_label( + provider: ProviderConfig, + value: str, + *, + model: str | None = None, +) -> ThinkingLevel: + """Resolve a provider-facing input label to a canonical Tau level.""" + selected_model = model or provider.default_model + override = catalog_model_override(provider.name, selected_model) + if override is not None and override.thinking_modes is not None: + label = value.strip().lower() + for level, mode in override.thinking_modes.items(): + if mode.label == label: + return level + return normalize_thinking_level(value) + + def provider_thinking_unavailable_reason( provider: ProviderConfig, *, @@ -683,6 +761,8 @@ def provider_thinking_unavailable_reason( ) -> str | None: """Explain why a provider/model pair has no configurable thinking modes.""" selected_model = model or provider.default_model + if provider_thinking_is_always_on(provider, model=selected_model): + return f"Reasoning is always enabled for {selected_model}" if provider.thinking_levels is None: if isinstance(provider, OpenAICodexProviderConfig): return ( @@ -705,6 +785,10 @@ def provider_default_thinking_level( levels = provider_thinking_levels(provider, model=model) if not levels: return None + selected_model = model or provider.default_model + override = catalog_model_override(provider.name, selected_model) + if override is not None and override.thinking_default in levels: + return override.thinking_default if provider.thinking_default in levels: return provider.thinking_default if DEFAULT_THINKING_LEVEL in levels: @@ -741,18 +825,137 @@ def openai_compatible_config_from_provider( ) +async def ensure_dynamic_provider_models( + settings: ProviderSettings, + *, + provider_name: str, + paths: TauPaths | None = None, + credential_store: FileCredentialStore | None = None, + client: httpx.AsyncClient | None = None, +) -> ProviderSettings: + """Populate a dynamic provider's model list at build time. + + Built-in providers flagged ``dynamic_models`` (such as Nebius Token Factory) + start with an empty model catalog. When Tau has usable credentials for the + selected provider, this fetches the live model list from the provider's + ``/models`` endpoint (with ``verbose=true``), persists it through the normal + provider-settings path, and returns the updated settings. It is best-effort: + any network, auth, or parse error leaves the settings unchanged so startup + never fails because of a model listing problem. + """ + entry = builtin_provider_entry(provider_name) + if entry is None or not entry.dynamic_models: + return settings + try: + provider = settings.get_provider(provider_name) + except ProviderConfigError: + return settings + if not isinstance(provider, OpenAICompatibleProviderConfig): + return settings + + store = credential_store or FileCredentialStore(credentials_path(paths) if paths else None) + if not provider_has_usable_credentials(provider, credential_reader=store): + return settings + + try: + if provider.name == "github-copilot": + provider = await _github_copilot_provider_for_model_listing( + provider, + credential_store=store, + ) + runtime_config = openai_compatible_config_from_provider( + provider, credential_reader=store + ) + models = await list_openai_compatible_models( + runtime_config, verbose=True, client=client + ) + except Exception: + return settings + + if not models: + return settings + + model_ids = tuple(model.id for model in models) + context_windows = { + **dict(provider.context_windows), + **{ + model.id: model.context_window + for model in models + if model.context_window is not None + }, + } + default_model = provider.default_model if provider.default_model in model_ids else model_ids[0] + updated_provider = replace( + provider, + models=model_ids, + default_model=default_model, + context_windows=context_windows, + ) + updated = _replace_provider(settings, updated_provider) + save_provider_settings(updated, paths) + return updated + + +async def _github_copilot_provider_for_model_listing( + provider: OpenAICompatibleProviderConfig, + *, + credential_store: FileCredentialStore, +) -> OpenAICompatibleProviderConfig: + """Return a Copilot provider config suitable for calling its OpenAI-compatible API.""" + headers = {**dict(provider.headers), **_github_copilot_headers()} + credential_name = provider.credential_name + if not credential_name: + return replace(provider, headers=headers) + credential = credential_store.get_oauth(credential_name) + if credential is None: + return replace(provider, headers=headers) + if oauth_credential_is_expired(credential): + credential = await refresh_github_copilot_token( + credential.refresh, + enterprise_domain=credential.account_id if credential.account_id != "github.com" else "", + ) + credential_store.set_oauth(credential_name, credential) + return replace( + provider, + base_url=github_copilot_base_url(credential.access, credential.account_id), + headers=headers, + ) + + +def _github_copilot_headers() -> dict[str, str]: + return { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", + "openai-intent": "conversation-panel", + } + + def anthropic_config_from_provider( provider: AnthropicProviderConfig, *, credential_reader: CredentialReader | None = None, + model: str | None = None, thinking_level: ThinkingLevel | None = None, ) -> AnthropicConfig: """Build Anthropic runtime config from durable settings.""" api_key = _api_key_from_provider(provider, credential_reader=credential_reader) - thinking_budget_tokens = _anthropic_thinking_budget_from_provider( + thinking_type = _anthropic_thinking_type_from_provider( provider, + model=model, thinking_level=thinking_level, ) + thinking_budget_tokens = ( + None + if thinking_type is not None + else _anthropic_thinking_budget_from_provider( + provider, + model=model, + thinking_level=thinking_level, + ) + ) + auth_header = "authorization" if provider.name == "github-copilot" else "x-api-key" return AnthropicConfig( api_key=api_key, base_url=provider.base_url.rstrip("/"), @@ -761,6 +964,8 @@ def anthropic_config_from_provider( max_retries=provider.max_retries, max_retry_delay_seconds=provider.max_retry_delay_seconds, thinking_budget_tokens=thinking_budget_tokens, + thinking_type=thinking_type, + auth_header=auth_header, ) @@ -780,11 +985,11 @@ def provider_has_usable_credentials( ) -> bool: """Return whether Tau can attempt calls for this provider without prompting setup.""" if provider.credential_name and credential_reader is not None: - if isinstance(provider, OpenAICodexProviderConfig): + if isinstance(provider, OpenAICodexProviderConfig) or provider.name == "github-copilot": get_oauth = getattr(credential_reader, "get_oauth", None) if get_oauth is not None and get_oauth(provider.credential_name) is not None: return True - elif credential_reader.get(provider.credential_name): + if credential_reader.get(provider.credential_name): return True return bool(environ.get(provider.api_key_env)) @@ -813,31 +1018,81 @@ def _reasoning_effort_from_provider( f"Thinking mode {normalized} is not available for " f"{provider.name}:{selected_model}. Available modes: {available}" ) - return reasoning_effort_for_level(normalized) + api_value = _thinking_api_value(provider, model=model, level=normalized) + return api_value if api_value is not None else reasoning_effort_for_level(normalized) def _anthropic_thinking_budget_from_provider( provider: AnthropicProviderConfig, *, + model: str | None = None, thinking_level: ThinkingLevel | None, ) -> int | None: if thinking_level is None or provider.thinking_parameter != "anthropic.thinking": return None - levels = provider_thinking_levels(provider) + levels = provider_thinking_levels(provider, model=model) if not levels: return None normalized = normalize_thinking_level(thinking_level) if normalized not in levels: + selected_model = model or provider.default_model available = ", ".join(levels) raise ProviderConfigError( f"Thinking mode {normalized} is not available for " - f"{provider.name}:{provider.default_model}. Available modes: {available}" + f"{provider.name}:{selected_model}. Available modes: {available}" ) return anthropic_thinking_budget_for_level(normalized) +def _anthropic_thinking_type_from_provider( + provider: AnthropicProviderConfig, + *, + model: str | None, + thinking_level: ThinkingLevel | None, +) -> AnthropicThinkingType | None: + if thinking_level is None or provider.thinking_parameter != "anthropic.thinking": + return None + normalized = normalize_thinking_level(thinking_level) + levels = provider_thinking_levels(provider, model=model) + if not levels: + return None + if normalized not in levels: + selected_model = model or provider.default_model + available = ", ".join(levels) + raise ProviderConfigError( + f"Thinking mode {normalized} is not available for " + f"{provider.name}:{selected_model}. Available modes: {available}" + ) + api_value = _thinking_api_value(provider, model=model, level=normalized) + if api_value == "adaptive": + return "adaptive" + if api_value == "disabled": + return "disabled" + return None + + +def _thinking_api_value( + provider: ProviderConfig, + *, + model: str | None, + level: ThinkingLevel, +) -> str | None: + mode = _thinking_mode(provider, model=model, level=level) + return mode.api_value if mode is not None else None + + +def _thinking_mode( + provider: ProviderConfig, *, model: str | None, level: ThinkingLevel +) -> ThinkingMode | None: + selected_model = model or provider.default_model + override = catalog_model_override(provider.name, selected_model) + if override is None or override.thinking_modes is None: + return None + return override.thinking_modes.get(level) + + def _provider_from_json(data: object) -> ProviderConfig: if not isinstance(data, dict): raise ProviderConfigError("Provider entries must be JSON objects") @@ -850,8 +1105,15 @@ def _provider_from_json(data: object) -> ProviderConfig: credential_name = _optional_string( data.get("credential_name"), f"providers[{name}].credential_name" ) - models = _string_tuple(data.get("models"), f"providers[{name}].models") - default_model = _string(data.get("default_model"), f"providers[{name}].default_model") + dynamic_models = bool(data.get("dynamic_models", False)) + if dynamic_models: + models = _optional_string_tuple(data.get("models"), f"providers[{name}].models") + default_model = _emptyable_string( + data.get("default_model"), f"providers[{name}].default_model" + ) + else: + models = _string_tuple(data.get("models"), f"providers[{name}].models") + default_model = _string(data.get("default_model"), f"providers[{name}].default_model") context_windows = _context_window_dict( data.get("context_windows", {}), f"providers[{name}].context_windows" ) @@ -883,7 +1145,7 @@ def _provider_from_json(data: object) -> ProviderConfig: thinking_parameter = _optional_thinking_parameter( data.get("thinking_parameter"), f"providers[{name}].thinking_parameter" ) - if default_model not in models: + if default_model and default_model not in models: models = (*models, default_model) if provider_type == "anthropic": return AnthropicProviderConfig( @@ -937,6 +1199,7 @@ def _provider_from_json(data: object) -> ProviderConfig: thinking_models=thinking_models, thinking_default=thinking_default, thinking_parameter=thinking_parameter, + dynamic_models=dynamic_models, ) @@ -949,6 +1212,11 @@ def _api_key_from_provider( credential = credential_reader.get(provider.credential_name) if credential: return credential + get_oauth = getattr(credential_reader, "get_oauth", None) + if get_oauth is not None: + oauth_credential = get_oauth(provider.credential_name) + if oauth_credential is not None: + return oauth_credential.access api_key = environ.get(provider.api_key_env) if api_key: @@ -1039,6 +1307,15 @@ def _optional_string(value: object, field_name: str) -> str | None: return value.strip() +def _emptyable_string(value: object, field_name: str) -> str: + """Parse a string field that may be empty (used by dynamic model catalogs).""" + if value is None: + return "" + if not isinstance(value, str): + raise ProviderConfigError(f"Provider field must be a string: {field_name}") + return value.strip() + + def _string(value: object, field_name: str) -> str: if not isinstance(value, str) or not value.strip(): raise ProviderConfigError(f"Provider field must be a non-empty string: {field_name}") diff --git a/src/tau_coding/provider_runtime.py b/src/tau_coding/provider_runtime.py index 89f04d3bb..4e9c76280 100644 --- a/src/tau_coding/provider_runtime.py +++ b/src/tau_coding/provider_runtime.py @@ -2,11 +2,14 @@ from __future__ import annotations +import asyncio +from dataclasses import replace from os import environ from typing import Protocol from tau_ai import ( AnthropicProvider, + LLMObserver, ModelProvider, OpenAICodexConfig, OpenAICodexCredentials, @@ -16,9 +19,12 @@ from tau_coding.credentials import FileCredentialStore, OAuthCredential from tau_coding.oauth import ( account_id_from_access_token, + github_copilot_base_url, oauth_credential_is_expired, + refresh_github_copilot_token, refresh_openai_codex_token, ) +from tau_coding.provider_catalog import catalog_model_override from tau_coding.provider_config import ( AnthropicProviderConfig, OpenAICodexProviderConfig, @@ -45,16 +51,25 @@ def create_model_provider( credential_store: FileCredentialStore | None = None, model: str | None = None, thinking_level: ThinkingLevel | None = None, + llm_observer: LLMObserver | None = None, ) -> ClosableModelProvider: """Create a runtime model provider from durable provider settings.""" credentials = credential_store or FileCredentialStore() + selected_model = model or provider.default_model + if provider.name == "github-copilot": + provider = _github_copilot_provider_config(provider, credential_store=credentials) + override = catalog_model_override(provider.name, selected_model) + if override is not None and override.kind == "anthropic": + provider = _anthropic_provider_config_for_model(provider, selected_model) if isinstance(provider, AnthropicProviderConfig): return AnthropicProvider( anthropic_config_from_provider( provider, credential_reader=credentials, + model=selected_model, thinking_level=thinking_level, - ) + ), + observer=llm_observer, ) if isinstance(provider, OpenAICodexProviderConfig): return OpenAICodexProvider( @@ -73,15 +88,100 @@ def create_model_provider( model=model, thinking_level=thinking_level, ), - ) + ), + observer=llm_observer, ) return OpenAICompatibleProvider( openai_compatible_config_from_provider( provider, credential_reader=credentials, - model=model, + model=selected_model, thinking_level=thinking_level, + ), + observer=llm_observer, + ) + + +def _github_copilot_provider_config( + provider: ProviderConfig, + *, + credential_store: FileCredentialStore, +) -> ProviderConfig: + """Refresh and adapt GitHub Copilot OAuth settings for runtime calls.""" + credential_name = provider.credential_name + if credential_name: + credential = credential_store.get_oauth(credential_name) + if credential is not None: + credential = _refresh_github_copilot_if_needed( + credential_name, + credential, + credential_store=credential_store, + ) + base_url = github_copilot_base_url(credential.access, credential.account_id) + return replace( + provider, + base_url=base_url, + headers={**dict(provider.headers), **_github_copilot_headers()}, + ) + return replace(provider, headers={**dict(provider.headers), **_github_copilot_headers()}) + + +def _refresh_github_copilot_if_needed( + credential_name: str, + credential: OAuthCredential, + *, + credential_store: FileCredentialStore, +) -> OAuthCredential: + """Refresh Copilot credentials when provider setup is outside an event loop.""" + if not oauth_credential_is_expired(credential): + return credential + try: + asyncio.get_running_loop() + except RuntimeError: + refreshed = asyncio.run( + refresh_github_copilot_token( + credential.refresh, + enterprise_domain=credential.account_id if credential.account_id != "github.com" else "", + ) ) + credential_store.set_oauth(credential_name, refreshed) + return refreshed + return credential + + +def _github_copilot_headers() -> dict[str, str]: + return { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", + "openai-intent": "conversation-panel", + } + + +def _anthropic_provider_config_for_model( + provider: ProviderConfig, + model: str, +) -> AnthropicProviderConfig: + """Adapt shared connection settings for a model served via Messages API.""" + if isinstance(provider, AnthropicProviderConfig): + return provider + return AnthropicProviderConfig( + name=provider.name, + base_url=provider.base_url, + api_key_env=provider.api_key_env, + credential_name=provider.credential_name, + models=provider.models, + default_model=model, + context_windows=provider.context_windows, + headers=provider.headers, + timeout_seconds=provider.timeout_seconds, + max_retries=provider.max_retries, + max_retry_delay_seconds=provider.max_retry_delay_seconds, + thinking_levels=provider.thinking_levels, + thinking_models=provider.thinking_models, + thinking_default=provider.thinking_default, + thinking_parameter="anthropic.thinking", ) diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index 60968c648..5eb1b95c4 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -56,7 +56,8 @@ from tau_ai.provider import CancellationToken from tau_coding.commands import CommandRegistry, create_default_command_registry from tau_coding.credentials import FileCredentialStore, OAuthCredential -from tau_coding.oauth import OAuthAuthInfo, OAuthPrompt, login_openai_codex +from tau_coding.diagnostics import llm_observer_from_env +from tau_coding.oauth import OAuthAuthInfo, OAuthPrompt, login_github_copilot, login_openai_codex from tau_coding.provider_catalog import ( BUILTIN_PROVIDER_CATALOG, ProviderCatalogEntry, @@ -65,8 +66,10 @@ from tau_coding.provider_config import ( ProviderConfig, ProviderSelection, + ensure_dynamic_provider_models, load_provider_settings, provider_config_from_catalog_entry, + provider_default_thinking_level, provider_has_usable_credentials, resolve_provider_selection, upsert_saved_provider, @@ -83,7 +86,6 @@ ) from tau_coding.session_manager import CodingSessionRecord, SessionManager from tau_coding.shell_config import load_shell_settings -from tau_coding.thinking import DEFAULT_THINKING_LEVEL from tau_coding.tui.adapter import TuiEventAdapter from tau_coding.tui.autocomplete import ( CompletionItem, @@ -173,6 +175,8 @@ def action_cycle_model(self) -> None: ... def action_toggle_tool_results(self) -> None: ... + def action_toggle_sidebar(self) -> None: ... + def action_toggle_thinking(self) -> None: ... def action_edit_queued_follow_up(self) -> bool: ... @@ -192,6 +196,9 @@ class SessionCompletionRecord(Protocol): updated_at: float +PASTE_DISPLAY_THRESHOLD = 2_000 + + class PromptInput(TextArea): """Multiline prompt input with completion key bindings.""" @@ -210,6 +217,9 @@ def __init__( self._base_bindings = self._bindings.copy() self._footer_mode: Literal["normal", "completion", "running"] = "normal" self._apply_prompt_bindings() + self._pending_full_content: str | None = None + self._last_text_length: int = 0 + self._placeholder: str = "" def set_footer_mode(self, mode: Literal["normal", "completion", "running"]) -> None: """Switch the prompt bindings shown by Textual's built-in footer.""" @@ -294,6 +304,10 @@ def action_toggle_tool_results(self) -> None: """Toggle app-level tool result display.""" self._completion_target().action_toggle_tool_results() + def action_toggle_sidebar(self) -> None: + """Toggle app-level sidebar display.""" + self._completion_target().action_toggle_sidebar() + def action_toggle_thinking(self) -> None: """Toggle app-level thinking-token display.""" self._completion_target().action_toggle_thinking() @@ -305,6 +319,9 @@ def action_clear_prompt(self) -> None: if self.text: self.text = "" self.move_cursor((0, 0)) + self._pending_full_content = None + self._last_text_length = 0 + self._placeholder = "" def get_line(self, line_index: int) -> Text: """Retrieve one prompt line with shell prefixes highlighted.""" @@ -342,6 +359,27 @@ def action_scroll_up(self) -> None: """Use up arrow for completion selection while focused.""" self.action_completion_previous() + def _detect_paste(self, new_text: str) -> None: + """Detect large paste and show placeholder in the input box.""" + new_len = len(new_text) + delta = new_len - self._last_text_length + + if delta > PASTE_DISPLAY_THRESHOLD and new_len > PASTE_DISPLAY_THRESHOLD: + char_count = new_len + line_count = new_text.count("\n") + 1 + kb = char_count / 1024 + parts: list[str] = [f"{char_count:,} characters"] + if line_count > 1: + parts.append(f"{line_count} lines") + if kb >= 1: + parts.append(f"{kb:.1f} KB") + self._placeholder = f"[Pasted content: {', '.join(parts)}]" + self._pending_full_content = new_text + self.text = self._placeholder + self.move_cursor((0, len(self._placeholder))) + + self._last_text_length = len(self.text) + async def on_key(self, event: Key) -> None: """Route completion and submission keys before default input handling.""" keybindings = self.tui_keybindings @@ -378,6 +416,9 @@ async def on_key(self, event: Key) -> None: elif event.key == keybindings.toggle_tool_results: event.stop() self._completion_target().action_toggle_tool_results() + elif event.key == keybindings.toggle_sidebar: + event.stop() + self._completion_target().action_toggle_sidebar() elif event.key == keybindings.toggle_thinking: event.stop() self._completion_target().action_toggle_thinking() @@ -414,7 +455,7 @@ class SessionPickerScreen(ModalScreen[str | None]): """Minimal modal picker for indexed sessions.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel"), + Binding("escape,ctrl+c", "cancel", "Cancel"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), Binding("enter", "select_cursor", "Select", show=False), @@ -495,7 +536,7 @@ class TreePickerScreen(ModalScreen[TreePickerResult | None]): """Modal picker for branching from a previous session entry.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel"), + Binding("escape,ctrl+c", "cancel", "Cancel"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), Binding("enter", "select_cursor", "Branch", show=False), @@ -654,7 +695,7 @@ def action_cancel(self) -> None: class BranchSummaryInstructionsScreen(ModalScreen[str | None]): """Prompt for custom branch-summary instructions.""" - BINDINGS: ClassVar[list[BindingEntry]] = [Binding("escape", "cancel", "Cancel")] + BINDINGS: ClassVar[list[BindingEntry]] = [Binding("escape,ctrl+c", "cancel", "Cancel")] def __init__(self, *, theme: TuiTheme) -> None: super().__init__() @@ -682,7 +723,7 @@ def on_key(self, event: Key) -> None: if event.key == "ctrl+enter": event.stop() self.action_submit() - elif event.key == "escape": + elif event.key in {"escape", "ctrl+c"}: event.stop() self.action_cancel() @@ -717,7 +758,7 @@ class CommandOutputScreen(ModalScreen[None]): """Dismissible modal for slash-command output.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "close", "Close"), + Binding("escape,ctrl+c", "close", "Close"), Binding("enter", "close", "Close"), Binding("up", "scroll_up", "Scroll up", show=False, priority=True), Binding("down", "scroll_down", "Scroll down", show=False, priority=True), @@ -767,7 +808,7 @@ class LoginProviderPickerScreen(ModalScreen[str | None]): """Provider picker for the TUI login flow.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel"), + Binding("escape,ctrl+c", "cancel", "Cancel"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), Binding("enter", "select_cursor", "Select", show=False), @@ -841,7 +882,7 @@ class LoginMethodPickerScreen(ModalScreen[str | None]): """Login method picker for the TUI login flow.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel", priority=True), + Binding("escape,ctrl+c", "cancel", "Cancel", priority=True), Binding("up", "cursor_up", "Up", show=False, priority=True), Binding("down", "cursor_down", "Down", show=False, priority=True), Binding("enter", "select_cursor", "Select", show=False, priority=True), @@ -951,7 +992,7 @@ class ThemePickerScreen(ModalScreen[TuiThemeName | None]): """Theme picker for the built-in TUI themes.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel", priority=True), + Binding("escape,ctrl+c", "cancel", "Cancel", priority=True), Binding("up", "cursor_up", "Up", show=False, priority=True), Binding("down", "cursor_down", "Down", show=False, priority=True), Binding("enter", "select_cursor", "Select", show=False, priority=True), @@ -1026,7 +1067,7 @@ class ModelPickerSearchInput(Input): """Search input that keeps model-picker control keys local to the picker.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel", show=False, priority=True), + Binding("escape,ctrl+c", "cancel", "Cancel", show=False, priority=True), Binding("tab", "toggle_mode", "Mode", show=False, priority=True), Binding("ctrl+i", "toggle_mode", "Mode", show=False, priority=True), Binding("up", "cursor_up", "Up", show=False, priority=True), @@ -1050,7 +1091,7 @@ def on_key(self, event: Key) -> None: event.stop() event.prevent_default() self.action_toggle_mode() - elif event.key == "escape": + elif event.key in {"escape", "ctrl+c"}: event.stop() event.prevent_default() self.action_cancel() @@ -1076,7 +1117,7 @@ class ModelPickerScreen(ModalScreen[ModelChoice | None]): """Model picker for the active TUI provider.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel"), + Binding("escape,ctrl+c", "cancel", "Cancel"), Binding("tab", "toggle_mode", "Mode", show=False, priority=True), Binding("ctrl+i", "toggle_mode", "Mode", show=False, priority=True), Binding("up", "cursor_up", "Up", show=False), @@ -1292,7 +1333,7 @@ class LoginScreen(ModalScreen[str | None]): """Password prompt for saving a provider API key.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel"), + Binding("escape,ctrl+c", "cancel", "Cancel"), ] def __init__(self, provider: ProviderCatalogEntry, *, theme: TuiTheme) -> None: @@ -1328,7 +1369,7 @@ class OAuthLoginScreen(ModalScreen[OAuthCredential | None]): """OAuth login flow for providers backed by subscription auth.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape", "cancel", "Cancel"), + Binding("escape,ctrl+c", "cancel", "Cancel"), ] def __init__(self, provider: ProviderCatalogEntry, *, theme: TuiTheme) -> None: @@ -1340,13 +1381,20 @@ def __init__(self, provider: ProviderCatalogEntry, *, theme: TuiTheme) -> None: def compose(self) -> ComposeResult: """Compose the OAuth login prompt.""" + if self.provider.name == "github-copilot": + help_text = "Starting GitHub device login..." + placeholder = "Waiting for device code" + else: + help_text = "Complete the browser login, or paste the redirect URL." + placeholder = "Paste redirect URL or authorization code" with Vertical(id="login-screen"): yield Static(f"Login: {self.provider.display_name}", id="login-title") - yield Static("Complete the browser login, or paste the redirect URL.", id="login-help") + yield Static(help_text, id="login-help") yield Static("", id="login-oauth-url") yield Input( - placeholder="Paste redirect URL or authorization code", + placeholder=placeholder, id="login-oauth-code", + disabled=self.provider.name == "github-copilot", ) yield Static("Enter submits - Escape closes", id="login-footer") @@ -1357,11 +1405,16 @@ def on_mount(self) -> None: async def _run_login(self) -> None: try: - credential = await login_openai_codex( - on_auth=self._show_auth, - on_prompt=self._prompt_for_code, - on_manual_code_input=self._manual_code_input, - ) + if self.provider.name == "github-copilot": + credential = await login_github_copilot( + on_auth=self._show_auth, + ) + else: + credential = await login_openai_codex( + on_auth=self._show_auth, + on_prompt=self._prompt_for_code, + on_manual_code_input=self._manual_code_input, + ) except Exception as exc: # noqa: BLE001 - surface OAuth failures in the TUI self.query_one("#login-help", Static).update(f"OAuth failed: {exc}") return @@ -1774,42 +1827,34 @@ def __init__( tui_settings: TuiSettings | None = None, startup_message: str | None = None, startup_notice: str | None = None, - startup_notices: Sequence[str] = (), initial_prompt: str | None = None, ) -> None: self.tui_settings = tui_settings or TuiSettings() self.startup_message = startup_message - legacy_notices = (startup_notice,) if startup_notice else () - self.startup_notices = tuple((*startup_notices, *legacy_notices)) + self.startup_notice = startup_notice self.initial_prompt = initial_prompt super().__init__() self._bindings = BindingsMap(_app_bindings(self.tui_settings.keybindings)) self.session = session self.state = TuiState(skills=session.skills) - for notice in self.startup_notices: - self.state.add_item("status", notice) + if startup_notice: + self.state.add_item("status", startup_notice) self._prompt_history: tuple[str, ...] = () self._load_session_messages_from_session() self.adapter = TuiEventAdapter(self.state) self._prompt_worker: Worker[None] | None = None + self._completion_visible_line_budget: int | None = None self._compaction_worker: Worker[None] | None = None self._prompt_run_id = 0 self._completion_state = CompletionState() - self._completion_visible_line_budget: int | None = None self._activity_frame = 0 self._activity_timer: Timer | None = None self._active_notification_keys: set[tuple[str, str]] = set() self._supports_pyperclip: bool | None = None - self._sync_header_title() - - def _sync_header_title(self) -> None: - """Reflect the active session name in Textual's header state.""" - self.title = "Tau" - self.sub_title = _session_header_sub_title(self.session) def _sync_text_selection_state(self) -> None: """Disable native text selection while the transcript is mutating.""" - type(self).ALLOW_SELECT = not self.state.running + self.ALLOW_SELECT = not self.state.running if self.state.running and self.screen_stack: with suppress(Exception): self.screen.clear_selection() @@ -1907,10 +1952,13 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None: """Update prompt autocomplete when the prompt text changes.""" if event.text_area.id != "prompt": return + prompt = self.query_one("#prompt", PromptInput) + prompt._detect_paste(event.text_area.text) self._sync_prompt_shell_mode(event.text_area.text) self._completion_visible_line_budget = None self._completion_state = self._build_completion_state(event.text_area.text) self._refresh_completions() + self._scroll_transcript_to_bottom() async def action_submit_prompt(self) -> None: """Submit the current prompt text or slash command.""" @@ -1926,6 +1974,11 @@ async def _submit_prompt_from_editor( streaming_behavior: Literal["steer", "follow_up"], ) -> None: prompt = self.query_one("#prompt", PromptInput) + if prompt._pending_full_content is not None: + full = prompt._pending_full_content + extra = prompt.text.removeprefix(prompt._placeholder) + prompt.text = full + extra + prompt._pending_full_content = None raw_text = prompt.text applied_completion = self._apply_selected_completion(raw_text) if applied_completion is not None and applied_completion != raw_text: @@ -2040,10 +2093,12 @@ async def _submit_prompt_from_editor( if self.state.running: self._remember_prompt(text) + self._scroll_transcript_to_bottom() await self._queue_prompt(text, streaming_behavior=streaming_behavior) return self._remember_prompt(text) + self._scroll_transcript_to_bottom() self._submit_prompt(text) def _remember_prompt(self, text: str) -> None: @@ -2104,11 +2159,15 @@ def _submit_prompt(self, text: str) -> None: self._prompt_run_id += 1 run_id = self._prompt_run_id self._follow_transcript_output() - self._refresh() + self._refresh(scroll_end=True) self._prompt_worker = self.run_worker(self._run_prompt(text, run_id), exclusive=True) def _follow_transcript_output(self) -> None: """Put the transcript back in follow mode for explicit user actions.""" + self._scroll_transcript_to_bottom() + + def _scroll_transcript_to_bottom(self) -> None: + """Force the transcript to the newest content immediately after user/agent activity.""" if not self.screen_stack: return with suppress(NoMatches): @@ -2127,7 +2186,7 @@ async def _run_terminal_command(self, command: str, *, add_to_context: bool) -> always_show_tool_result=True, ) self._follow_transcript_output() - self._refresh() + self._refresh(scroll_end=True) try: result = await run_terminal_command(command, add_to_context=add_to_context) @@ -2140,7 +2199,7 @@ async def _run_terminal_command(self, command: str, *, add_to_context: bool) -> output=str(exc), ) self._notify(f"Could not run command: {exc}", severity="error") - self._refresh() + self._refresh(scroll_end=True) return if item_index >= len(self.state.items): @@ -2153,13 +2212,14 @@ async def _run_terminal_command(self, command: str, *, add_to_context: bool) -> output=result.output, ) self._follow_transcript_output() - self._refresh() + self._refresh(scroll_end=True) def _set_tui_theme(self, theme: TuiThemeName) -> None: self.tui_settings = TuiSettings( keybindings=self.tui_settings.keybindings, theme=theme, auto_copy_selection=self.tui_settings.auto_copy_selection, + show_sidebar=self.tui_settings.show_sidebar, ) save_tui_settings(self.tui_settings) self.refresh_css(animate=False) @@ -2178,7 +2238,7 @@ async def _queue_prompt( except Exception as exc: # noqa: BLE001 - surface queueing failures in the TUI self._notify(f"Could not queue message: {exc}", severity="error") return - self._refresh() + self._refresh(scroll_end=True) async def _run_prompt(self, text: str, run_id: int | None = None) -> None: """Run one prompt and stream session events into the TUI state.""" @@ -2192,6 +2252,7 @@ async def _run_prompt(self, text: str, run_id: int | None = None) -> None: if isinstance(event, ErrorEvent) and not event.recoverable: _attach_diagnostic_log_path_to_error(self.state, self.session) await self._apply_streaming_transcript_event(event) + await asyncio.sleep(0) except Exception as exc: # noqa: BLE001 - surface unexpected worker errors in the TUI if active_run_id != self._prompt_run_id: return @@ -2200,7 +2261,7 @@ async def _run_prompt(self, text: str, run_id: int | None = None) -> None: self.state.add_item("error", message) self.state.running = False self._sync_text_selection_state() - self._refresh() + self._refresh(scroll_end=True) finally: if active_run_id == self._prompt_run_id: self._prompt_worker = None @@ -2220,13 +2281,14 @@ async def _apply_streaming_transcript_event(self, event: AgentEvent) -> None: self._refresh_chrome() return if isinstance(event, AgentEndEvent): - await transcript.finish_assistant_message() + await transcript.finish_assistant_message(scroll_end=True) self._refresh_chrome() + self._scroll_transcript_to_bottom() return if isinstance(event, MessageStartEvent): return if isinstance(event, MessageDeltaEvent): - await transcript.append_assistant_delta(event.delta, theme=theme) + await transcript.append_assistant_delta(event.delta, theme=theme, scroll_end=True) self._sync_activity_indicator() return if isinstance(event, ThinkingDeltaEvent): @@ -2234,6 +2296,7 @@ async def _apply_streaming_transcript_event(self, event: AgentEvent) -> None: event.delta, theme=theme, show_thinking=self.state.show_thinking, + scroll_end=True, ) self._sync_activity_indicator() return @@ -2242,31 +2305,34 @@ async def _apply_streaming_transcript_event(self, event: AgentEvent) -> None: self._refresh() return if event.message.role == "assistant": - await transcript.finish_assistant_message(event.message.content) + await transcript.finish_assistant_message(event.message.content, scroll_end=True) self._refresh_chrome() + self._scroll_transcript_to_bottom() return return if isinstance(event, ToolExecutionStartEvent): - await transcript.finish_assistant_message() + await transcript.finish_assistant_message(scroll_end=True) await transcript.append_item( self.state.items[-1], theme=theme, show_tool_results=self.state.show_tool_results, + scroll_end=True, ) self._refresh_chrome() return if isinstance(event, ToolExecutionUpdateEvent | RetryEvent | ErrorEvent): - await transcript.finish_assistant_message() + await transcript.finish_assistant_message(scroll_end=True) if self.state.items: await transcript.append_item( self.state.items[-1], theme=theme, show_tool_results=self.state.show_tool_results, + scroll_end=True, ) self._refresh_chrome() return if isinstance(event, ToolExecutionEndEvent): - self._refresh() + self._refresh(scroll_end=True) return if isinstance(event, QueueUpdateEvent): self._refresh_chrome() @@ -2464,6 +2530,19 @@ def action_toggle_tool_results(self) -> None: self._refresh() self._notify("Tool results expanded." if expanded else "Tool results collapsed.") + def action_toggle_sidebar(self) -> None: + """Toggle the session sidebar and persist the preference.""" + self.tui_settings = TuiSettings( + keybindings=self.tui_settings.keybindings, + theme=self.tui_settings.theme, + auto_copy_selection=self.tui_settings.auto_copy_selection, + show_sidebar=not self.tui_settings.show_sidebar, + ) + save_tui_settings(self.tui_settings) + self._update_responsive_layout(self.size.width, self.size.height) + self._refresh_footer_bindings() + self._notify("Sidebar shown." if self.tui_settings.show_sidebar else "Sidebar hidden.") + def action_toggle_thinking(self) -> None: """Toggle thinking-token display in the transcript.""" self.state.toggle_thinking() @@ -2626,7 +2705,7 @@ def _open_login(self, provider_name: str) -> None: if entry is None: self._notify(f"Unknown provider: {provider_name}", severity="error") return - if entry.kind == "openai-codex": + if entry.kind == "openai-codex" or entry.name == "github-copilot": self.push_screen( OAuthLoginScreen(entry, theme=self.tui_settings.resolved_theme), callback=lambda credential: self._handle_oauth_login_result(entry, credential), @@ -2882,16 +2961,15 @@ def _notify( ) self.notify(message, severity=severity, markup=False) - def _refresh(self) -> None: + def _refresh(self, *, scroll_end: bool = False) -> None: theme = self.tui_settings.resolved_theme self._refresh_chrome(theme=theme) transcript = self.query_one("#transcript", TranscriptView) - transcript.update_from_state(self.state, theme=theme) + transcript.update_from_state(self.state, theme=theme, scroll_end=scroll_end) def _refresh_chrome(self, *, theme: TuiTheme | None = None) -> None: """Refresh non-transcript chrome without remounting transcript blocks.""" theme = theme or self.tui_settings.resolved_theme - self._sync_header_title() self._sync_text_selection_state() self._sync_queue_state() sidebar = self.query_one("#sidebar", SessionSidebar) @@ -3026,7 +3104,8 @@ def _initial_completion_line_budget(self) -> int: ) def _update_responsive_layout(self, width: int, height: int) -> None: - show_sidebar = width >= SIDEBAR_MIN_WIDTH and height >= SIDEBAR_MIN_HEIGHT + enough_space = width >= SIDEBAR_MIN_WIDTH and height >= SIDEBAR_MIN_HEIGHT + show_sidebar = self.tui_settings.show_sidebar and enough_space self.set_class(not show_sidebar, "-hide-sidebar") def _build_completion_state(self, text: str) -> CompletionState: @@ -3354,12 +3433,6 @@ def _named_session_title(title: str | None) -> str | None: return stripped -def _session_header_sub_title(session: CodingSession) -> str: - """Return the session label shown beside Tau in the TUI header.""" - title = _named_session_title(getattr(session, "session_title", None)) - return title or "Untitled session" - - def _login_provider_label(provider: ProviderCatalogEntry) -> str: return f"{provider.display_name}\n {provider.name}" @@ -3367,13 +3440,21 @@ def _login_provider_label(provider: ProviderCatalogEntry) -> str: def _subscription_login_providers( providers: Sequence[ProviderCatalogEntry], ) -> tuple[ProviderCatalogEntry, ...]: - return tuple(provider for provider in providers if provider.kind == "openai-codex") + return tuple( + provider + for provider in providers + if provider.kind == "openai-codex" or provider.name == "github-copilot" + ) def _api_key_login_providers( providers: Sequence[ProviderCatalogEntry], ) -> tuple[ProviderCatalogEntry, ...]: - return tuple(provider for provider in providers if provider.kind != "openai-codex") + return tuple( + provider + for provider in providers + if provider.kind != "openai-codex" and provider.name != "github-copilot" + ) def _stored_credential_providers( @@ -3553,6 +3634,7 @@ def _app_bindings(keybindings: TuiKeybindings) -> list[Binding]: priority=True, ), Binding(keybindings.toggle_tool_results, "toggle_tool_results", "Tool results"), + Binding(keybindings.toggle_sidebar, "toggle_sidebar", "Sidebar"), Binding(keybindings.toggle_thinking, "toggle_thinking", "Thinking tokens"), Binding(keybindings.copy_message, "clear_prompt", "Clear input"), Binding(keybindings.quit, "quit", "Quit"), @@ -3584,6 +3666,7 @@ def _prompt_bindings( priority=True, ), Binding(keybindings.cancel, "cancel", "Close", priority=True), + Binding("ctrl+c", "cancel", "Close", show=False, priority=True), ] return bindings + _hidden_prompt_bindings(keybindings, visible_bindings=bindings) if mode == "running": @@ -3591,6 +3674,7 @@ def _prompt_bindings( Binding("enter", "submit_prompt", "Steer", priority=True), Binding(keybindings.queue_follow_up, "submit_follow_up", "Follow-up", priority=True), Binding(keybindings.cancel, "cancel", "Cancel", priority=True), + Binding("ctrl+c", "cancel", "Cancel", show=False, priority=True), Binding( keybindings.toggle_thinking, "toggle_thinking", @@ -3603,6 +3687,7 @@ def _prompt_bindings( "Tools", priority=True, ), + Binding(keybindings.toggle_sidebar, "toggle_sidebar", "Sidebar", priority=True), ] return bindings + _hidden_prompt_bindings(keybindings, visible_bindings=bindings) bindings = [ @@ -3612,6 +3697,7 @@ def _prompt_bindings( Binding(keybindings.session_picker, "open_session_picker", "Sessions", priority=True), Binding(keybindings.thinking_cycle, "cycle_thinking", "Thinking", priority=True), Binding(keybindings.model_cycle, "cycle_model", "Model", priority=True), + Binding(keybindings.toggle_sidebar, "toggle_sidebar", "Sidebar", priority=True), Binding( keybindings.copy_message, "clear_prompt", @@ -3628,7 +3714,11 @@ def _hidden_prompt_bindings( *, visible_bindings: Sequence[Binding], ) -> list[Binding]: - visible_keys = {key for binding in visible_bindings for key in binding.key.split(",")} + visible_keys = { + key.strip() + for binding in visible_bindings + for key in binding.key.split(",") + } candidates = ( (keybindings.command_palette, "open_command_palette"), (keybindings.session_picker, "open_session_picker"), @@ -3636,6 +3726,7 @@ def _hidden_prompt_bindings( (keybindings.thinking_cycle, "cycle_thinking"), (keybindings.model_cycle, "cycle_model"), (keybindings.toggle_tool_results, "toggle_tool_results"), + (keybindings.toggle_sidebar, "toggle_sidebar"), (keybindings.toggle_thinking, "toggle_thinking"), (keybindings.copy_message, "clear_prompt"), (keybindings.accept_completion, "accept_completion"), @@ -3801,13 +3892,16 @@ async def run_tui_app( initial_prompt: str | None = None, session_manager: SessionManager | None = None, startup_notice: str | None = None, - startup_notices: Sequence[str] = (), ) -> None: """Create the default provider/session and run the Textual app.""" if new_session and session_id is not None: raise RuntimeError("--resume and --new-session cannot be used together") provider_settings = load_provider_settings() + for provider in provider_settings.providers: + provider_settings = await ensure_dynamic_provider_models( + provider_settings, provider_name=provider.name + ) shell_settings = load_shell_settings() manager = session_manager or SessionManager() record = _explicit_resume_record( @@ -3823,11 +3917,16 @@ async def run_tui_app( ) startup_message: str | None = None runtime_provider_config: ProviderConfig | None = selection.provider + llm_observer = llm_observer_from_env() try: + startup_thinking_level = provider_default_thinking_level( + selection.provider, model=selection.model + ) provider = create_model_provider( selection.provider, model=selection.model, - thinking_level=DEFAULT_THINKING_LEVEL, + thinking_level=startup_thinking_level, + llm_observer=llm_observer, ) except RuntimeError: login_required_message = ( @@ -3862,15 +3961,14 @@ async def run_tui_app( auto_compact_token_threshold=auto_compact_token_threshold, index_on_first_persist=index_on_first_persist, shell_command_prefix=shell_settings.shell_command_prefix, + llm_observer=llm_observer, ) ) - legacy_notices = (startup_notice,) if startup_notice else () - all_startup_notices = tuple((*startup_notices, *legacy_notices)) app = TauTuiApp( session, tui_settings=load_tui_settings(), startup_message=startup_message, - startup_notices=all_startup_notices, + startup_notice=startup_notice, initial_prompt=initial_prompt, ) await app.run_async() diff --git a/tests/test_oauth.py b/tests/test_oauth.py index f683cef49..2a379b8a0 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -11,7 +11,10 @@ OPENAI_CODEX_CLIENT_ID, account_id_from_access_token, create_openai_codex_authorization_flow, + github_copilot_base_url, + normalize_github_domain, parse_authorization_input, + refresh_github_copilot_token, refresh_openai_codex_token, ) @@ -93,6 +96,49 @@ def handler(request: httpx.Request) -> httpx.Response: assert credential.expires == expires * 1000 +def test_github_copilot_base_url_extracts_proxy_endpoint_and_enterprise_domain() -> None: + token = "x;proxy-ep=proxy.enterprise.githubcopilot.com;y" + + assert ( + github_copilot_base_url(token) + == "https://api.enterprise.githubcopilot.com" + ) + assert ( + github_copilot_base_url("", "example.ghe.com") + == "https://copilot-api.example.ghe.com" + ) + assert ( + github_copilot_base_url("") + == "https://api.individual.githubcopilot.com" + ) + + +def test_normalize_github_domain_accepts_url_or_hostname() -> None: + assert normalize_github_domain("https://example.ghe.com/org") == "example.ghe.com" + assert normalize_github_domain("example.ghe.com") == "example.ghe.com" + assert normalize_github_domain("") == "" + + +@pytest.mark.anyio +async def test_refresh_github_copilot_token_returns_copilot_oauth_credential() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url == "https://api.github.com/copilot_internal/v2/token" + assert request.headers["authorization"] == "Bearer github-token" + assert request.headers["copilot-integration-id"] == "vscode-chat" + return httpx.Response( + 200, + json={"token": "copilot-token", "expires_at": 2_000_000}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + credential = await refresh_github_copilot_token("github-token", client=client) + + assert credential.access == "copilot-token" + assert credential.refresh == "github-token" + assert credential.account_id == "github.com" + assert credential.expires == 2_000_000_000 - 5 * 60 * 1000 + + def _jwt(account_id: str, *, expires: int | None = None) -> str: payload = {OPENAI_CODEX_ACCOUNT_CLAIM: {"chatgpt_account_id": account_id}} if expires is not None: diff --git a/tests/test_provider_config.py b/tests/test_provider_config.py index 845cf07f0..b93ab3eac 100644 --- a/tests/test_provider_config.py +++ b/tests/test_provider_config.py @@ -1,6 +1,7 @@ import json from pathlib import Path +import httpx import pytest from tau_coding.credentials import FileCredentialStore, OAuthCredential @@ -14,6 +15,7 @@ ProviderSettings, ScopedModelConfig, anthropic_config_from_provider, + ensure_dynamic_provider_models, load_provider_settings, openai_compatible_config_from_provider, provider_default_thinking_level, @@ -35,8 +37,12 @@ def test_load_provider_settings_missing_file_uses_openai_default(tmp_path: Path) "openai", "openai-codex", "anthropic", + "github-copilot", "openrouter", "huggingface", + "deepseek", + "opencode-go", + "nebius", ] assert settings.providers[0].default_model == DEFAULT_MODEL assert settings.get_provider("anthropic").api_key_env == "ANTHROPIC_API_KEY" @@ -51,6 +57,7 @@ def test_builtin_openai_declares_model_scoped_thinking_capabilities() -> None: huggingface = settings.get_provider("huggingface") codex = settings.get_provider("openai-codex") anthropic = settings.get_provider("anthropic") + copilot = settings.get_provider("github-copilot") assert openai.context_windows["gpt-5.5"] == 272_000 assert openai.context_windows["gpt-5.5-pro"] == 1_050_000 @@ -111,6 +118,19 @@ def test_builtin_openai_declares_model_scoped_thinking_capabilities() -> None: ) assert provider_thinking_unavailable_reason(anthropic, model="claude-sonnet-4-6") is None assert provider_thinking_levels(anthropic, model="claude-haiku-4-5") == () + assert copilot.default_model == "gpt-5.5" + assert copilot.dynamic_models is True + assert "claude-sonnet-5" in copilot.models + assert "claude-sonnet-4.6" in copilot.models + assert "gemini-3.5-flash" in copilot.models + assert provider_thinking_levels(copilot, model="gpt-5.4") == ( + "off", + "low", + "medium", + "high", + "xhigh", + ) + assert provider_thinking_levels(copilot, model="claude-sonnet-4.6") == () def test_save_provider_settings_writes_backup_when_replacing(tmp_path: Path) -> None: @@ -148,7 +168,12 @@ def test_save_provider_settings_writes_backup_when_replacing(tmp_path: Path) -> ) -def test_save_and_load_provider_settings_round_trip(tmp_path: Path) -> None: +def test_save_and_load_provider_settings_round_trip( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) paths = TauPaths(home=tmp_path / ".tau") settings = ProviderSettings( default_provider="local", @@ -234,10 +259,14 @@ def test_upsert_openai_compatible_provider_replaces_and_sets_default() -> None: assert updated.default_provider == "local" assert [item.name for item in updated.providers] == [ "anthropic", + "deepseek", + "github-copilot", "huggingface", "local", + "nebius", "openai", "openai-codex", + "opencode-go", "openrouter", ] assert replaced.get_provider("local").default_model == "llama" @@ -694,6 +723,7 @@ def test_load_provider_settings_restores_builtin_providers_with_stored_credentia tmp_path: Path, ) -> None: for env_name in ( + "DEEPSEEK_API_KEY", "OPENAI_API_KEY", "OPENAI_CODEX_ACCESS_TOKEN", "ANTHROPIC_API_KEY", @@ -860,3 +890,166 @@ def test_openai_compatible_provider_config_rejects_invalid_retries() -> None: OpenAICompatibleProviderConfig(name="local", max_retries=-1) with pytest.raises(ProviderConfigError, match="0 or greater"): OpenAICompatibleProviderConfig(name="local", max_retry_delay_seconds=-1) + + +def test_nebius_builtin_entry_is_dynamic_with_empty_catalog() -> None: + settings = ProviderSettings() + nebius = settings.get_provider("nebius") + + assert isinstance(nebius, OpenAICompatibleProviderConfig) + assert nebius.base_url == "https://api.tokenfactory.nebius.com/v1" + assert nebius.api_key_env == "NEBIUS_TOKEN_FACTORY_API_KEY" + assert nebius.models == () + assert nebius.default_model == "" + + +@pytest.mark.anyio +async def test_ensure_dynamic_provider_models_populates_nebius_at_build( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NEBIUS_TOKEN_FACTORY_API_KEY", "nebius-key") + paths = TauPaths(home=tmp_path / ".tau") + settings = load_provider_settings(paths) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["verbose"] == "true" + return httpx.Response( + 200, + json={ + "data": [ + {"id": "meta-llama/Llama-3.3-70B-Instruct", "context_window": 131072}, + {"id": "deepseek-ai/DeepSeek-R1-0528"}, + ] + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + updated = await ensure_dynamic_provider_models( + settings, provider_name="nebius", paths=paths, client=client + ) + + nebius = updated.get_provider("nebius") + assert nebius.models == ( + "meta-llama/Llama-3.3-70B-Instruct", + "deepseek-ai/DeepSeek-R1-0528", + ) + assert nebius.default_model == "meta-llama/Llama-3.3-70B-Instruct" + assert nebius.context_windows["meta-llama/Llama-3.3-70B-Instruct"] == 131072 + + reloaded = load_provider_settings(paths) + assert reloaded.get_provider("nebius").models == nebius.models + + +@pytest.mark.anyio +async def test_ensure_dynamic_provider_models_uses_copilot_proxy_and_headers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False) + paths = TauPaths(home=tmp_path / ".tau") + credential_store = FileCredentialStore(tmp_path / ".tau" / "credentials.json") + credential_store.set_oauth( + "github-copilot", + OAuthCredential( + access="tid=1;proxy-ep=proxy.enterprise.test;token", + refresh="github-refresh", + expires=9999999999999, + account_id="github.com", + ), + ) + settings = load_provider_settings(paths) + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "data": [ + {"id": "gpt-5.5", "context_window": 272000}, + {"id": "claude-sonnet-5", "context_window": 1000000}, + {"id": "gemini-3.5-flash"}, + ] + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + updated = await ensure_dynamic_provider_models( + settings, + provider_name="github-copilot", + paths=paths, + credential_store=credential_store, + client=client, + ) + + copilot = updated.get_provider("github-copilot") + assert requests[0].url == "https://api.enterprise.test/models?verbose=true" + assert requests[0].headers["authorization"] == "Bearer tid=1;proxy-ep=proxy.enterprise.test;token" + assert requests[0].headers["copilot-integration-id"] == "vscode-chat" + assert copilot.models == ("gpt-5.5", "claude-sonnet-5", "gemini-3.5-flash") + assert copilot.default_model == "gpt-5.5" + assert copilot.context_windows["claude-sonnet-5"] == 1000000 + + +@pytest.mark.anyio +async def test_ensure_dynamic_provider_models_leaves_non_dynamic_unchanged( + tmp_path: Path, +) -> None: + paths = TauPaths(home=tmp_path / ".tau") + settings = load_provider_settings(paths) + openai_before = settings.get_provider("openai") + + updated = await ensure_dynamic_provider_models( + settings, provider_name="openai", paths=paths + ) + + assert updated.get_provider("openai").models == openai_before.models + + +@pytest.mark.anyio +async def test_ensure_dynamic_provider_models_without_credentials_is_noop( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NEBIUS_TOKEN_FACTORY_API_KEY", raising=False) + paths = TauPaths(home=tmp_path / ".tau") + settings = load_provider_settings(paths) + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("should not call the models endpoint without credentials") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + updated = await ensure_dynamic_provider_models( + settings, provider_name="nebius", paths=paths, client=client + ) + + assert updated.get_provider("nebius").models == () + + +def test_github_copilot_provider_uses_stored_oauth_access_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False) + provider = ProviderSettings().get_provider("github-copilot") + + class FakeCredentials: + def get(self, name: str) -> str | None: + return None + + def get_oauth(self, name: str) -> OAuthCredential | None: + if name != "github-copilot": + return None + return OAuthCredential( + access="copilot-token", + refresh="github-token", + expires=999_999_999_999, + account_id="github.com", + ) + + config = openai_compatible_config_from_provider( + provider, + credential_reader=FakeCredentials(), + ) + + assert config.api_key == "copilot-token" diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 84f3c8d61..716af78d8 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -1,5 +1,5 @@ from collections.abc import AsyncIterator, Mapping -from json import loads +from json import dumps, loads import httpx import pytest @@ -18,6 +18,10 @@ AnthropicConfig, AnthropicProvider, FakeProvider, + redact_json_value, + redact_headers, + LLMObservation, + ModelInfo, OpenAICodexConfig, OpenAICodexCredentials, OpenAICodexProvider, @@ -30,6 +34,7 @@ ProviderTextDeltaEvent, ProviderThinkingDeltaEvent, ProviderToolCallEvent, + list_openai_compatible_models, openai_compatible_config_from_env, ) @@ -38,6 +43,14 @@ async def _collect(stream: AsyncIterator[object]) -> list[object]: return [event async for event in stream] +class RecordingLLMObserver: + def __init__(self) -> None: + self.records: list[LLMObservation] = [] + + def record(self, observation: LLMObservation) -> None: + self.records.append(observation) + + @pytest.mark.anyio async def test_fake_provider_replays_scripted_events() -> None: scripted = [ @@ -517,7 +530,8 @@ def handler(_request: httpx.Request) -> httpx.Response: assert isinstance(events[-1], ProviderErrorEvent) assert events[-1].message == ( - "OpenAI Codex request failed with status 400: The requested model does not exist." + "OpenAI Codex request failed with status 400: " + "The requested model does not exist." ) assert events[-1].data == { "status_code": 400, @@ -1150,9 +1164,13 @@ def handler(request: httpx.Request) -> httpx.Response: UserMessage(content="weather in Paris?"), AssistantMessage( content="", - tool_calls=[ToolCall(id="call_1", name="get_weather", arguments={"city": "Paris"})], + tool_calls=[ + ToolCall(id="call_1", name="get_weather", arguments={"city": "Paris"}) + ], + ), + ToolResultMessage( + tool_call_id="call_1", name="get_weather", content='{"temp_c": 19}' ), - ToolResultMessage(tool_call_id="call_1", name="get_weather", content='{"temp_c": 19}'), UserMessage(content="summarize"), ] @@ -1273,50 +1291,6 @@ def handler(_request: httpx.Request) -> httpx.Response: assert end.finish_reason == "tool_calls" -@pytest.mark.anyio -async def test_responses_api_streams_refusal_as_text() -> None: - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - text=( - 'data: {"type":"response.refusal.delta","delta":"I can"}\n\n' - 'data: {"type":"response.refusal.delta","delta":"not help with that."}\n\n' - 'data: {"type":"response.refusal.done",' - '"refusal":"I cannot help with that."}\n\n' - 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = OpenAICompatibleProvider( - OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), - client=client, - ) - - events = await _collect( - provider.stream_response( - model="gpt-5.5", - system="You are Tau.", - messages=[UserMessage(content="unsafe request")], - tools=[], - ) - ) - - assert [event.type for event in events] == [ - "response_start", - "text_delta", - "text_delta", - "response_end", - ] - text_deltas = [e.delta for e in events if isinstance(e, ProviderTextDeltaEvent)] - assert text_deltas == ["I can", "not help with that."] - end = events[-1] - assert isinstance(end, ProviderResponseEndEvent) - assert end.message.content == "I cannot help with that." - assert end.finish_reason == "stop" - - @pytest.mark.anyio async def test_responses_api_streams_reasoning_summary_as_thinking() -> None: def handler(_request: httpx.Request) -> httpx.Response: @@ -1538,3 +1512,488 @@ def handler(_request: httpx.Request) -> httpx.Response: assert isinstance(end, ProviderResponseEndEvent) assert end.message.content == "partial" assert end.finish_reason == "length" + + +@pytest.mark.anyio +async def test_list_openai_compatible_models_uses_verbose_and_parses_ids() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "object": "list", + "data": [ + { + "id": "meta-llama/Llama-3.3-70B-Instruct", + "object": "model", + "owned_by": "system", + "context_window": 131072, + }, + {"id": "deepseek-ai/DeepSeek-R1-0528", "object": "model"}, + {"id": "meta-llama/Llama-3.3-70B-Instruct"}, + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + models = await list_openai_compatible_models( + OpenAICompatibleConfig( + api_key="nebius-key", + base_url="https://api.tokenfactory.nebius.com/v1", + ), + verbose=True, + client=client, + ) + + assert [model.id for model in models] == [ + "meta-llama/Llama-3.3-70B-Instruct", + "deepseek-ai/DeepSeek-R1-0528", + ] + assert isinstance(models[0], ModelInfo) + assert models[0].context_window == 131072 + assert models[1].context_window is None + assert len(requests) == 1 + assert requests[0].url.path == "/v1/models" + assert requests[0].url.params["verbose"] == "true" + assert requests[0].headers["authorization"] == "Bearer nebius-key" + + +@pytest.mark.anyio +async def test_list_openai_compatible_models_omits_verbose_when_false() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert "verbose" not in request.url.params + return httpx.Response(200, json={"data": [{"id": "model-a"}]}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + models = await list_openai_compatible_models( + OpenAICompatibleConfig(api_key="key", base_url="https://example.test/v1"), + verbose=False, + client=client, + ) + + assert [model.id for model in models] == ["model-a"] + + +@pytest.mark.anyio +async def test_openai_compatible_provider_can_send_responses_reasoning_effort() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text='data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig( + api_key="test-key", + base_url="https://example.test/v1", + reasoning_effort="high", + reasoning_effort_parameter="reasoning.effort", + ), + client=client, + ) + + await _collect( + provider.stream_response( + model="gpt-5.5", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + ) + ) + + payload = loads(requests[0].content) + assert payload["reasoning"] == {"effort": "high", "summary": "auto"} + assert "reasoning_effort" not in payload + + +@pytest.mark.anyio +async def test_openai_codex_provider_retries_transient_response_failed_event() -> None: + requests: list[httpx.Request] = [] + + async def credentials() -> OpenAICodexCredentials: + return OpenAICodexCredentials(access_token="access-token", account_id="account-1") + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response( + 200, + text=( + 'data: {"type":"response.failed","response":{"status":"failed",' + '"status_code":503,"error":{"code":"service_unavailable",' + '"message":"temporarily unavailable"}}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + return httpx.Response( + 200, + text=( + 'data: {"type":"response.output_text.delta","delta":"ok"}\n\n' + 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICodexProvider( + OpenAICodexConfig( + credential_resolver=credentials, + base_url="https://chatgpt.test/backend-api", + max_retries=1, + max_retry_delay_seconds=0, + ), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="gpt-5.5", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + ) + ) + + assert len(requests) == 2 + assert isinstance(events[1], ProviderRetryEvent) + assert events[1].attempt == 2 + assert events[1].max_attempts == 2 + assert [event.type for event in events] == [ + "response_start", + "retry", + "response_start", + "text_delta", + "response_end", + ] + assert isinstance(events[-1], ProviderResponseEndEvent) + assert events[-1].message.content == "ok" + + +@pytest.mark.anyio +async def test_openai_codex_provider_stops_after_max_response_failed_retries() -> None: + requests: list[httpx.Request] = [] + + async def credentials() -> OpenAICodexCredentials: + return OpenAICodexCredentials(access_token="access-token", account_id="account-1") + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text=( + 'data: {"type":"response.failed","response":{"status":"failed",' + '"status_code":503,"error":{"message":"temporarily unavailable"}}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICodexProvider( + OpenAICodexConfig( + credential_resolver=credentials, + base_url="https://chatgpt.test/backend-api", + max_retries=1, + max_retry_delay_seconds=0, + ), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="gpt-5.5", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + ) + ) + + assert len(requests) == 2 + assert [event.type for event in events] == [ + "response_start", + "retry", + "response_start", + "error", + ] + assert isinstance(events[-1], ProviderErrorEvent) + assert events[-1].retryable is True + assert events[-1].data == { + "attempts": 2, + "event": { + "type": "response.failed", + "response": { + "status": "failed", + "status_code": 503, + "error": {"message": "temporarily unavailable"}, + }, + } + } + + +@pytest.mark.anyio +async def test_anthropic_provider_retries_transient_stream_error() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response( + 200, + text=( + 'data: {"type":"error","error":{"type":"overloaded_error",' + '"message":"overloaded"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + return httpx.Response( + 200, + text=( + 'data: {"type":"content_block_delta","index":0,' + '"delta":{"type":"text_delta","text":"ok"}}\n\n' + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="test-key", + base_url="https://api.anthropic.test/v1", + max_retries=1, + max_retry_delay_seconds=0, + ), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="claude-test", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + ) + ) + + assert len(requests) == 2 + assert isinstance(events[1], ProviderRetryEvent) + assert [event.type for event in events] == [ + "response_start", + "retry", + "response_start", + "text_delta", + "response_end", + ] + + +@pytest.mark.anyio +async def test_anthropic_provider_does_not_retry_non_transient_stream_error() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text=( + 'data: {"type":"error","error":{"type":"invalid_request_error",' + '"message":"bad request"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="test-key", + base_url="https://api.anthropic.test/v1", + max_retries=3, + max_retry_delay_seconds=0, + ), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="claude-test", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + ) + ) + + assert len(requests) == 1 + assert [event.type for event in events] == ["response_start", "error"] + assert isinstance(events[-1], ProviderErrorEvent) + assert events[-1].retryable is False + assert events[-1].data == { + "attempts": 1, + "event": { + "type": "error", + "error": {"type": "invalid_request_error", "message": "bad request"}, + }, + } + + +def test_llm_observation_redacts_headers_and_prompt_like_text() -> None: + headers = redact_headers( + { + "Authorization": "Bearer secret-key", + "X-Api-Key": "api-secret", + "X-HF-Bill-To": "my-org", + } + ) + + assert headers["Authorization"] == "[REDACTED]" + assert headers["X-Api-Key"] == "[REDACTED]" + assert headers["X-HF-Bill-To"] == "my-org" + + body = redact_json_value( + { + "model": "test-model", + "messages": [{"role": "user", "content": "secret prompt"}], + "input": {"type": "function_call", "path": "/private/file.txt"}, + } + ) + + assert isinstance(body, dict) + assert body["model"] == "test-model" + messages = body["messages"] + assert isinstance(messages, list) + message = messages[0] + assert isinstance(message, dict) + assert message["role"] == "user" + content = message["content"] + assert isinstance(content, dict) + assert content["redacted"] is True + assert content["length"] == len("secret prompt") + input_value = body["input"] + assert isinstance(input_value, dict) + assert input_value["type"] == "function_call" + path = input_value["path"] + assert isinstance(path, dict) + assert path["redacted"] is True + + +@pytest.mark.anyio +async def test_openai_compatible_provider_observes_redacted_request_and_response() -> None: + observer = RecordingLLMObserver() + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + text='data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\n', + headers={"content-type": "text/event-stream", "x-request-id": "req-1"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), + client=client, + observer=observer, + ) + + await _collect( + provider.stream_response( + model="test-model", + system="system secret", + messages=[UserMessage(content="user secret")], + tools=[], + ) + ) + + assert [record.kind for record in observer.records] == ["request", "response"] + request = observer.records[0] + assert request.provider == "openai-compatible" + assert request.model == "test-model" + assert request.method == "POST" + assert request.url == "https://example.test/v1/chat/completions" + assert request.attempt == 1 + assert request.stream is True + assert request.data["request"]["headers"]["Authorization"] == "[REDACTED]" # type: ignore[index] + request_body = request.data["request"]["body"] # type: ignore[index] + assert request_body["model"] == "test-model" # type: ignore[index] + assert request_body["stream"] is True # type: ignore[index] + system_content = request_body["messages"][0]["content"] # type: ignore[index] + user_content = request_body["messages"][1]["content"] # type: ignore[index] + assert system_content["redacted"] is True # type: ignore[index] + assert system_content["length"] == len("system secret") # type: ignore[index] + assert user_content["redacted"] is True # type: ignore[index] + assert "system secret" not in dumps(request.to_json()) + assert "user secret" not in dumps(request.to_json()) + assert "test-key" not in dumps(request.to_json()) + + response = observer.records[1] + assert response.data["response"]["status_code"] == 200 # type: ignore[index] + assert response.data["response"]["headers"]["x-request-id"] == "req-1" # type: ignore[index] + + +@pytest.mark.anyio +async def test_openai_compatible_provider_observes_redacted_error_body() -> None: + observer = RecordingLLMObserver() + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(400, text="bad request includes user secret") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), + client=client, + observer=observer, + ) + + events = await _collect( + provider.stream_response( + model="test-model", + system="You are Tau.", + messages=[UserMessage(content="Say hello")], + tools=[], + ) + ) + + assert isinstance(events[-1], ProviderErrorEvent) + assert [record.kind for record in observer.records] == ["request", "response", "error"] + error = observer.records[-1] + error_body = error.data["error"]["body"] # type: ignore[index] + assert error_body["redacted"] is True # type: ignore[index] + assert error_body["length"] == len("bad request includes user secret") # type: ignore[index] + assert "bad request includes user secret" not in dumps(error.to_json()) + + +@pytest.mark.anyio +async def test_anthropic_provider_can_use_bearer_auth_for_copilot() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text='data: {"type":"message_stop"}\n\n', + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="copilot-token", + base_url="https://api.individual.githubcopilot.com", + auth_header="authorization", + ), + client=client, + ) + async for _event in provider.stream_response( + model="claude-sonnet-4.6", + system="", + messages=[UserMessage(content="hello")], + tools=[], + ): + pass + + request = requests[0] + assert request.headers["authorization"] == "Bearer copilot-token" + assert "x-api-key" not in request.headers diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index b5bb8ffea..a67749af0 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3728,7 +3728,10 @@ async def test_tui_login_subscription_opens_oauth_provider_picker() -> None: assert isinstance(app.screen, LoginProviderPickerScreen) provider_list = app.screen.query_one("#login-provider-list", ListView) labels = [str(item.query_one(Label).render()) for item in provider_list.children] - assert labels == ["OpenAI Codex subscription\n openai-codex"] + assert labels == [ + "OpenAI Codex subscription\n openai-codex", + "GitHub Copilot\n github-copilot", + ] assert "gpt-5.5" not in "\n".join(labels) @@ -5094,3 +5097,11 @@ def __init__(self, records: list[CodingSessionRecord]) -> None: def list_sessions(self, cwd: Path | None = None) -> list[CodingSessionRecord]: del cwd return self._records + + +def test_github_copilot_is_subscription_login_provider() -> None: + subscription = tui_app._subscription_login_providers(tui_app.BUILTIN_PROVIDER_CATALOG) + api_key = tui_app._api_key_login_providers(tui_app.BUILTIN_PROVIDER_CATALOG) + + assert any(provider.name == "github-copilot" for provider in subscription) + assert all(provider.name != "github-copilot" for provider in api_key) From be600895b907a20db1e43708ed9aa3c8eb6a9ed1 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Thu, 2 Jul 2026 23:59:45 +0100 Subject: [PATCH 02/10] Fix Copilot device login TUI flow --- tests/test_tui_app.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index a67749af0..a486c11cc 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -33,6 +33,7 @@ ) from tau_coding.commands import CommandResult from tau_coding.credentials import FileCredentialStore, OAuthCredential +from tau_coding.oauth import OAuthAuthInfo from tau_coding.prompt_templates import PromptTemplate from tau_coding.provider_config import ( OpenAICodexProviderConfig, @@ -3481,6 +3482,60 @@ async def fake_login_openai_codex(**_kwargs: object) -> OAuthCredential: assert "refresh-token" in credentials +@pytest.mark.anyio +async def test_tui_login_github_copilot_starts_device_flow_without_enterprise_prompt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + credential_future = asyncio.get_running_loop().create_future() + login_kwargs: dict[str, object] = {} + + async def fake_login_github_copilot(**kwargs: object) -> OAuthCredential: + login_kwargs.update(kwargs) + on_auth = kwargs["on_auth"] + assert callable(on_auth) + on_auth( + OAuthAuthInfo( + url="https://github.com/login/device", + instructions="Enter code: ABCD-1234", + ) + ) + return await credential_future + + monkeypatch.setattr(tui_app, "login_github_copilot", fake_login_github_copilot) + session = FakeSession() + app = TauTuiApp(session) + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt") + prompt.value = "/login github-copilot" + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, OAuthLoginScreen) + assert "on_prompt" not in login_kwargs + assert str(app.screen.query_one("#login-oauth-url", Static).render()) == ( + "https://github.com/login/device" + ) + assert str(app.screen.query_one("#login-help", Static).render()) == "Enter code: ABCD-1234" + assert app.screen.query_one("#login-oauth-code", Input).disabled is True + credential_future.set_result( + OAuthCredential( + access="copilot-token", + refresh="github-token", + expires=123456, + account_id="github.com", + ) + ) + await pilot.pause() + + assert session.provider_name == "github-copilot" + credentials = (tmp_path / ".tau" / "credentials.json").read_text(encoding="utf-8") + assert '"type": "oauth"' in credentials + assert "github-token" in credentials + + @pytest.mark.anyio async def test_tui_login_preserves_existing_scoped_models_and_providers( monkeypatch: pytest.MonkeyPatch, From c5f1195ab616b7a15e4e92991e8f3892b3debabf Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Fri, 3 Jul 2026 16:09:38 +0100 Subject: [PATCH 03/10] Fix Copilot model discovery and Claude routing --- src/tau_coding/provider_catalog.py | 28 +++++++++++++++++----------- tests/test_provider_runtime.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/tau_coding/provider_catalog.py b/src/tau_coding/provider_catalog.py index d0934ac91..c8b7b3634 100644 --- a/src/tau_coding/provider_catalog.py +++ b/src/tau_coding/provider_catalog.py @@ -157,25 +157,38 @@ class ProviderCatalogEntry: "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", - "gpt-5.2", "gpt-5-mini", + "claude-sonnet-5", "claude-sonnet-4.6", + "claude-sonnet-4.5", "claude-opus-4.8", + "claude-opus-4.7", + "claude-opus-4.6", "claude-haiku-4.5", + "gemini-3.5-flash", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", "gemini-2.5-pro", + "mai-code-1-flash-picker", ), - default_model="gpt-5.4", + default_model="gpt-5.5", docs_url="https://docs.github.com/copilot", context_windows={ "gpt-5.5": 272_000, "gpt-5.4": 272_000, "gpt-5.4-mini": 400_000, "gpt-5.3-codex": 400_000, - "gpt-5.2": 400_000, "gpt-5-mini": 400_000, + "claude-sonnet-5": 1_000_000, "claude-sonnet-4.6": 1_000_000, + "claude-sonnet-4.5": 1_000_000, "claude-opus-4.8": 1_000_000, + "claude-opus-4.7": 1_000_000, + "claude-opus-4.6": 1_000_000, "claude-haiku-4.5": 200_000, + "gemini-3.5-flash": 1_048_576, + "gemini-3.1-pro-preview": 1_048_576, + "gemini-3-flash-preview": 1_048_576, "gemini-2.5-pro": 1_048_576, }, thinking_levels=("off", "low", "medium", "high", "xhigh"), @@ -184,18 +197,11 @@ class ProviderCatalogEntry: "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", - "gpt-5.2", "gpt-5-mini", - "claude-sonnet-4.6", - "claude-opus-4.8", ), thinking_default="medium", thinking_parameter="reasoning_effort", - model_overrides={ - "claude-sonnet-4.6": ProviderModelOverride(kind="anthropic"), - "claude-opus-4.8": ProviderModelOverride(kind="anthropic"), - "claude-haiku-4.5": ProviderModelOverride(kind="anthropic"), - }, + dynamic_models=True, ), ProviderCatalogEntry( name="openrouter", diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py index 57681dbd2..37d15bca6 100644 --- a/tests/test_provider_runtime.py +++ b/tests/test_provider_runtime.py @@ -1,9 +1,9 @@ import pytest -from tau_ai import OpenAICodexProvider +from tau_ai import OpenAICodexProvider, OpenAICompatibleProvider from tau_coding import provider_runtime from tau_coding.credentials import FileCredentialStore, OAuthCredential -from tau_coding.provider_config import OpenAICodexProviderConfig +from tau_coding.provider_config import OpenAICodexProviderConfig, ProviderSettings from tau_coding.provider_runtime import OpenAICodexCredentialResolver, create_model_provider @@ -18,6 +18,30 @@ def test_create_model_provider_returns_openai_codex_provider(tmp_path) -> None: assert isinstance(provider, OpenAICodexProvider) +def test_create_model_provider_keeps_github_copilot_claude_openai_compatible(tmp_path) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_oauth( + "github-copilot", + OAuthCredential( + access="tid=1;proxy-ep=proxy.enterprise.test;token", + refresh="github-refresh", + expires=9999999999999, + account_id="github.com", + ), + ) + provider_config = ProviderSettings().get_provider("github-copilot") + + provider = create_model_provider( + provider_config, + credential_store=store, + model="claude-sonnet-5", + ) + + assert isinstance(provider, OpenAICompatibleProvider) + assert provider._config.base_url == "https://api.enterprise.test" + assert provider._config.headers["Copilot-Integration-Id"] == "vscode-chat" + + def test_create_model_provider_maps_codex_reasoning_effort_like_pi(tmp_path) -> None: store = FileCredentialStore(tmp_path / "credentials.json") provider_config = OpenAICodexProviderConfig( From 76c23562d34e5967782479da3c8e6a18aa158b82 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Sat, 4 Jul 2026 13:37:12 +0100 Subject: [PATCH 04/10] Keep Copilot provider branch upstream-focused --- src/tau_ai/__init__.py | 3 + src/tau_ai/models.py | 93 ++++++ src/tau_coding/provider_catalog.py | 200 +++++++++++- src/tau_coding/provider_runtime.py | 17 +- src/tau_coding/tui/app.py | 224 +++++-------- tests/test_provider_runtime.py | 11 +- tests/test_tau_ai.py | 491 +++-------------------------- tests/test_tui_app.py | 64 +--- 8 files changed, 437 insertions(+), 666 deletions(-) create mode 100644 src/tau_ai/models.py diff --git a/src/tau_ai/__init__.py b/src/tau_ai/__init__.py index fa327f915..c43ad8b47 100644 --- a/src/tau_ai/__init__.py +++ b/src/tau_ai/__init__.py @@ -23,6 +23,7 @@ ProviderToolCallEvent, ) from tau_ai.fake import FakeProvider +from tau_ai.models import ModelInfo, list_openai_compatible_models from tau_ai.openai_codex import ( DEFAULT_OPENAI_CODEX_BASE_URL, OpenAICodexConfig, @@ -42,6 +43,7 @@ "DEFAULT_OPENAI_COMPATIBLE_TIMEOUT_SECONDS", "DEFAULT_OPENAI_CODEX_BASE_URL", "FakeProvider", + "ModelInfo", "ModelProvider", "OpenAICodexConfig", "OpenAICodexCredentials", @@ -56,5 +58,6 @@ "ProviderThinkingDeltaEvent", "ProviderTextDeltaEvent", "ProviderToolCallEvent", + "list_openai_compatible_models", "openai_compatible_config_from_env", ] diff --git a/src/tau_ai/models.py b/src/tau_ai/models.py new file mode 100644 index 000000000..a8068ed41 --- /dev/null +++ b/src/tau_ai/models.py @@ -0,0 +1,93 @@ +"""Model discovery for OpenAI-compatible providers. + +Some OpenAI-compatible endpoints (for example Nebius Token Factory) expose a +``GET /v1/models`` listing that can be expanded with a ``verbose`` query +parameter. Tau uses this to populate a provider's model list dynamically at +build time instead of hardcoding a catalog. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from json import loads +from typing import Any + +import httpx + +from tau_ai.env import OpenAICompatibleConfig + + +@dataclass(frozen=True, slots=True) +class ModelInfo: + """One model advertised by an OpenAI-compatible ``/models`` endpoint.""" + + id: str + context_window: int | None = None + + +async def list_openai_compatible_models( + config: OpenAICompatibleConfig, + *, + verbose: bool = False, + client: httpx.AsyncClient | None = None, +) -> tuple[ModelInfo, ...]: + """List models from an OpenAI-compatible ``/models`` endpoint. + + When ``verbose`` is true, the ``verbose=true`` query parameter is sent so + providers that support it (such as Nebius Token Factory) return the full + model catalog with metadata. The response is parsed tolerantly: only the + ``id`` of each entry in ``data`` is required, and an optional integer + context-window field is extracted when present. + """ + headers: dict[str, str] = {**(dict(config.headers or {}))} + headers.setdefault("Authorization", f"Bearer {config.api_key}") + params: dict[str, str] = {} + if verbose: + params["verbose"] = "true" + url = f"{config.base_url.rstrip('/')}/models" + + owns_client = client is None + http_client = client or httpx.AsyncClient(timeout=config.timeout_seconds) + try: + response = await http_client.get(url, headers=headers, params=params) + response.raise_for_status() + payload = loads(response.content) + finally: + if owns_client: + await http_client.aclose() + + data = _data_array(payload) + models: list[ModelInfo] = [] + seen: set[str] = set() + for entry in data: + model_id = _model_id(entry) + if model_id is None or model_id in seen: + continue + seen.add(model_id) + models.append(ModelInfo(id=model_id, context_window=_context_window(entry))) + return tuple(models) + + +def _data_array(payload: object) -> list[Mapping[str, Any]]: + if not isinstance(payload, Mapping): + return [] + data = payload.get("data") + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, Mapping)] + + +def _model_id(entry: Mapping[str, Any]) -> str | None: + model_id = entry.get("id") + if isinstance(model_id, str) and model_id.strip(): + return model_id.strip() + return None + + +def _context_window(entry: Mapping[str, Any]) -> int | None: + for field_name in ("context_window", "context_length", "max_context_length"): + value = entry.get(field_name) + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return value + return None diff --git a/src/tau_coding/provider_catalog.py b/src/tau_coding/provider_catalog.py index c8b7b3634..62779554d 100644 --- a/src/tau_coding/provider_catalog.py +++ b/src/tau_coding/provider_catalog.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Literal @@ -10,6 +11,24 @@ ProviderKind = Literal["openai-compatible", "anthropic", "openai-codex"] +@dataclass(frozen=True, slots=True) +class ThinkingMode: + """A canonical Tau thinking level's provider-specific behavior.""" + + api_value: str | None = None + label: str | None = None + + +@dataclass(frozen=True, slots=True) +class ProviderModelOverride: + """Built-in model behavior that differs from its provider defaults.""" + + kind: ProviderKind | None = None + thinking_modes: Mapping[ThinkingLevel, ThinkingMode] | None = None + thinking_default: ThinkingLevel | None = None + always_thinking: bool = False + + @dataclass(frozen=True, slots=True) class ProviderCatalogEntry: """A built-in provider Tau can present during login.""" @@ -28,6 +47,8 @@ class ProviderCatalogEntry: thinking_models: tuple[str, ...] = () thinking_default: ThinkingLevel | None = None thinking_parameter: ThinkingParameter | None = None + model_overrides: dict[str, ProviderModelOverride] | None = None + dynamic_models: bool = False BUILTIN_PROVIDER_CATALOG: tuple[ProviderCatalogEntry, ...] = ( @@ -126,19 +147,25 @@ class ProviderCatalogEntry: api_key_env="ANTHROPIC_API_KEY", credential_name="anthropic", models=( + "claude-fable-5", + "claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", ), - default_model="claude-sonnet-4-6", + default_model="claude-sonnet-5", docs_url="https://docs.anthropic.com", context_windows={ + "claude-fable-5": 1_000_000, + "claude-sonnet-5": 1_000_000, "claude-sonnet-4-6": 1_000_000, - "claude-opus-4-8": 200_000, + "claude-opus-4-8": 1_000_000, "claude-haiku-4-5": 200_000, }, thinking_levels=("off", "minimal", "low", "medium", "high", "xhigh"), thinking_models=( + "claude-fable-5", + "claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8", ), @@ -293,6 +320,165 @@ class ProviderCatalogEntry: thinking_default="medium", thinking_parameter="reasoning_effort", ), + ProviderCatalogEntry( + name="deepseek", + display_name="DeepSeek", + kind="openai-compatible", + base_url="https://api.deepseek.com/v1", + api_key_env="DEEPSEEK_API_KEY", + credential_name="deepseek", + models=( + "deepseek-v4-flash", + "deepseek-v4-pro", + ), + default_model="deepseek-v4-flash", + docs_url="https://api-docs.deepseek.com", + context_windows={ + "deepseek-v4-flash": 1_048_576, + "deepseek-v4-pro": 1_048_576, + }, + thinking_levels=("off", "low", "medium", "high", "xhigh"), + thinking_models=( + "deepseek-v4-flash", + "deepseek-v4-pro", + ), + thinking_default="high", + thinking_parameter="reasoning_effort", + ), + ProviderCatalogEntry( + name="opencode-go", + display_name="OpenCode Go", + kind="openai-compatible", + base_url="https://opencode.ai/zen/go/v1", + api_key_env="OPENCODE_GO_API_KEY", + credential_name="opencode-go", + models=( + "glm-5.2", + "glm-5.1", + "kimi-k2.7-code", + "kimi-k2.6", + "deepseek-v4-pro", + "deepseek-v4-flash", + "mimo-v2.5", + "mimo-v2.5-pro", + "minimax-m3", + "minimax-m2.7", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + ), + default_model="deepseek-v4-pro", + docs_url="https://opencode.ai/docs/go", + context_windows={ + "glm-5.2": 1_000_000, + "glm-5.1": 202_752, + "kimi-k2.7-code": 262_144, + "kimi-k2.6": 262_144, + "deepseek-v4-pro": 1_000_000, + "deepseek-v4-flash": 1_000_000, + "mimo-v2.5": 1_000_000, + "mimo-v2.5-pro": 1_048_576, + "minimax-m3": 1_000_000, + "minimax-m2.7": 204_800, + "qwen3.7-max": 1_000_000, + "qwen3.7-plus": 1_000_000, + "qwen3.6-plus": 1_000_000, + }, + thinking_levels=("off", "low", "medium", "high", "xhigh"), + thinking_models=( + "glm-5.2", + "deepseek-v4-pro", + "deepseek-v4-flash", + "minimax-m3", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + ), + thinking_default="medium", + thinking_parameter="reasoning_effort", + model_overrides={ + "glm-5.2": ProviderModelOverride( + thinking_modes={ + "high": ThinkingMode(api_value="high"), + "xhigh": ThinkingMode(api_value="max", label="max"), + }, + thinking_default="high", + ), + "glm-5.1": ProviderModelOverride(always_thinking=True), + "kimi-k2.7-code": ProviderModelOverride(always_thinking=True), + "kimi-k2.6": ProviderModelOverride(always_thinking=True), + "deepseek-v4-pro": ProviderModelOverride( + thinking_modes={ + "high": ThinkingMode(api_value="high"), + "xhigh": ThinkingMode(api_value="max", label="max"), + }, + thinking_default="high", + ), + "deepseek-v4-flash": ProviderModelOverride( + thinking_modes={ + "high": ThinkingMode(api_value="high"), + "xhigh": ThinkingMode(api_value="max", label="max"), + }, + thinking_default="high", + ), + "mimo-v2.5": ProviderModelOverride(always_thinking=True), + "mimo-v2.5-pro": ProviderModelOverride(always_thinking=True), + "minimax-m3": ProviderModelOverride( + kind="anthropic", + thinking_modes={ + "off": ThinkingMode(api_value="disabled"), + "high": ThinkingMode(api_value="adaptive", label="on"), + }, + thinking_default="high", + ), + "minimax-m2.7": ProviderModelOverride(kind="anthropic", always_thinking=True), + "qwen3.7-max": ProviderModelOverride( + kind="anthropic", + thinking_modes={ + "off": ThinkingMode(api_value="disabled"), + "low": ThinkingMode(), + "medium": ThinkingMode(), + "high": ThinkingMode(), + "xhigh": ThinkingMode(), + }, + thinking_default="medium", + ), + "qwen3.7-plus": ProviderModelOverride( + kind="anthropic", + thinking_modes={ + "off": ThinkingMode(api_value="disabled"), + "low": ThinkingMode(), + "medium": ThinkingMode(), + "high": ThinkingMode(), + "xhigh": ThinkingMode(), + }, + thinking_default="medium", + ), + "qwen3.6-plus": ProviderModelOverride( + kind="anthropic", + thinking_modes={ + "off": ThinkingMode(api_value="disabled"), + "low": ThinkingMode(), + "medium": ThinkingMode(), + "high": ThinkingMode(), + "xhigh": ThinkingMode(), + }, + thinking_default="medium", + ), + }, + ), + ProviderCatalogEntry( + name="nebius", + display_name="Nebius Token Factory", + kind="openai-compatible", + base_url="https://api.tokenfactory.nebius.com/v1", + api_key_env="NEBIUS_TOKEN_FACTORY_API_KEY", + credential_name="nebius", + models=(), + default_model="", + docs_url="https://docs.tokenfactory.nebius.com", + dynamic_models=True, + ), ) @@ -302,3 +488,13 @@ def builtin_provider_entry(name: str) -> ProviderCatalogEntry | None: if entry.name == name: return entry return None + + +def catalog_model_override(provider_name: str, model: str | None) -> ProviderModelOverride | None: + """Return built-in metadata for a provider/model pair.""" + if model is None: + return None + entry = builtin_provider_entry(provider_name) + if entry is None or entry.model_overrides is None: + return None + return entry.model_overrides.get(model) diff --git a/src/tau_coding/provider_runtime.py b/src/tau_coding/provider_runtime.py index 4e9c76280..5b1de0542 100644 --- a/src/tau_coding/provider_runtime.py +++ b/src/tau_coding/provider_runtime.py @@ -9,7 +9,6 @@ from tau_ai import ( AnthropicProvider, - LLMObserver, ModelProvider, OpenAICodexConfig, OpenAICodexCredentials, @@ -51,7 +50,6 @@ def create_model_provider( credential_store: FileCredentialStore | None = None, model: str | None = None, thinking_level: ThinkingLevel | None = None, - llm_observer: LLMObserver | None = None, ) -> ClosableModelProvider: """Create a runtime model provider from durable provider settings.""" credentials = credential_store or FileCredentialStore() @@ -59,7 +57,11 @@ def create_model_provider( if provider.name == "github-copilot": provider = _github_copilot_provider_config(provider, credential_store=credentials) override = catalog_model_override(provider.name, selected_model) - if override is not None and override.kind == "anthropic": + if ( + provider.name != "github-copilot" + and override is not None + and override.kind == "anthropic" + ): provider = _anthropic_provider_config_for_model(provider, selected_model) if isinstance(provider, AnthropicProviderConfig): return AnthropicProvider( @@ -68,8 +70,7 @@ def create_model_provider( credential_reader=credentials, model=selected_model, thinking_level=thinking_level, - ), - observer=llm_observer, + ) ) if isinstance(provider, OpenAICodexProviderConfig): return OpenAICodexProvider( @@ -88,8 +89,7 @@ def create_model_provider( model=model, thinking_level=thinking_level, ), - ), - observer=llm_observer, + ) ) return OpenAICompatibleProvider( openai_compatible_config_from_provider( @@ -97,8 +97,7 @@ def create_model_provider( credential_reader=credentials, model=selected_model, thinking_level=thinking_level, - ), - observer=llm_observer, + ) ) diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index 5eb1b95c4..e9979802f 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -56,7 +56,6 @@ from tau_ai.provider import CancellationToken from tau_coding.commands import CommandRegistry, create_default_command_registry from tau_coding.credentials import FileCredentialStore, OAuthCredential -from tau_coding.diagnostics import llm_observer_from_env from tau_coding.oauth import OAuthAuthInfo, OAuthPrompt, login_github_copilot, login_openai_codex from tau_coding.provider_catalog import ( BUILTIN_PROVIDER_CATALOG, @@ -66,10 +65,8 @@ from tau_coding.provider_config import ( ProviderConfig, ProviderSelection, - ensure_dynamic_provider_models, load_provider_settings, provider_config_from_catalog_entry, - provider_default_thinking_level, provider_has_usable_credentials, resolve_provider_selection, upsert_saved_provider, @@ -86,6 +83,7 @@ ) from tau_coding.session_manager import CodingSessionRecord, SessionManager from tau_coding.shell_config import load_shell_settings +from tau_coding.thinking import DEFAULT_THINKING_LEVEL from tau_coding.tui.adapter import TuiEventAdapter from tau_coding.tui.autocomplete import ( CompletionItem, @@ -175,8 +173,6 @@ def action_cycle_model(self) -> None: ... def action_toggle_tool_results(self) -> None: ... - def action_toggle_sidebar(self) -> None: ... - def action_toggle_thinking(self) -> None: ... def action_edit_queued_follow_up(self) -> bool: ... @@ -196,9 +192,6 @@ class SessionCompletionRecord(Protocol): updated_at: float -PASTE_DISPLAY_THRESHOLD = 2_000 - - class PromptInput(TextArea): """Multiline prompt input with completion key bindings.""" @@ -217,9 +210,6 @@ def __init__( self._base_bindings = self._bindings.copy() self._footer_mode: Literal["normal", "completion", "running"] = "normal" self._apply_prompt_bindings() - self._pending_full_content: str | None = None - self._last_text_length: int = 0 - self._placeholder: str = "" def set_footer_mode(self, mode: Literal["normal", "completion", "running"]) -> None: """Switch the prompt bindings shown by Textual's built-in footer.""" @@ -304,10 +294,6 @@ def action_toggle_tool_results(self) -> None: """Toggle app-level tool result display.""" self._completion_target().action_toggle_tool_results() - def action_toggle_sidebar(self) -> None: - """Toggle app-level sidebar display.""" - self._completion_target().action_toggle_sidebar() - def action_toggle_thinking(self) -> None: """Toggle app-level thinking-token display.""" self._completion_target().action_toggle_thinking() @@ -319,9 +305,6 @@ def action_clear_prompt(self) -> None: if self.text: self.text = "" self.move_cursor((0, 0)) - self._pending_full_content = None - self._last_text_length = 0 - self._placeholder = "" def get_line(self, line_index: int) -> Text: """Retrieve one prompt line with shell prefixes highlighted.""" @@ -359,27 +342,6 @@ def action_scroll_up(self) -> None: """Use up arrow for completion selection while focused.""" self.action_completion_previous() - def _detect_paste(self, new_text: str) -> None: - """Detect large paste and show placeholder in the input box.""" - new_len = len(new_text) - delta = new_len - self._last_text_length - - if delta > PASTE_DISPLAY_THRESHOLD and new_len > PASTE_DISPLAY_THRESHOLD: - char_count = new_len - line_count = new_text.count("\n") + 1 - kb = char_count / 1024 - parts: list[str] = [f"{char_count:,} characters"] - if line_count > 1: - parts.append(f"{line_count} lines") - if kb >= 1: - parts.append(f"{kb:.1f} KB") - self._placeholder = f"[Pasted content: {', '.join(parts)}]" - self._pending_full_content = new_text - self.text = self._placeholder - self.move_cursor((0, len(self._placeholder))) - - self._last_text_length = len(self.text) - async def on_key(self, event: Key) -> None: """Route completion and submission keys before default input handling.""" keybindings = self.tui_keybindings @@ -416,9 +378,6 @@ async def on_key(self, event: Key) -> None: elif event.key == keybindings.toggle_tool_results: event.stop() self._completion_target().action_toggle_tool_results() - elif event.key == keybindings.toggle_sidebar: - event.stop() - self._completion_target().action_toggle_sidebar() elif event.key == keybindings.toggle_thinking: event.stop() self._completion_target().action_toggle_thinking() @@ -455,7 +414,7 @@ class SessionPickerScreen(ModalScreen[str | None]): """Minimal modal picker for indexed sessions.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel"), + Binding("escape", "cancel", "Cancel"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), Binding("enter", "select_cursor", "Select", show=False), @@ -536,7 +495,7 @@ class TreePickerScreen(ModalScreen[TreePickerResult | None]): """Modal picker for branching from a previous session entry.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel"), + Binding("escape", "cancel", "Cancel"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), Binding("enter", "select_cursor", "Branch", show=False), @@ -695,7 +654,7 @@ def action_cancel(self) -> None: class BranchSummaryInstructionsScreen(ModalScreen[str | None]): """Prompt for custom branch-summary instructions.""" - BINDINGS: ClassVar[list[BindingEntry]] = [Binding("escape,ctrl+c", "cancel", "Cancel")] + BINDINGS: ClassVar[list[BindingEntry]] = [Binding("escape", "cancel", "Cancel")] def __init__(self, *, theme: TuiTheme) -> None: super().__init__() @@ -723,7 +682,7 @@ def on_key(self, event: Key) -> None: if event.key == "ctrl+enter": event.stop() self.action_submit() - elif event.key in {"escape", "ctrl+c"}: + elif event.key == "escape": event.stop() self.action_cancel() @@ -758,7 +717,7 @@ class CommandOutputScreen(ModalScreen[None]): """Dismissible modal for slash-command output.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "close", "Close"), + Binding("escape", "close", "Close"), Binding("enter", "close", "Close"), Binding("up", "scroll_up", "Scroll up", show=False, priority=True), Binding("down", "scroll_down", "Scroll down", show=False, priority=True), @@ -808,7 +767,7 @@ class LoginProviderPickerScreen(ModalScreen[str | None]): """Provider picker for the TUI login flow.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel"), + Binding("escape", "cancel", "Cancel"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), Binding("enter", "select_cursor", "Select", show=False), @@ -882,7 +841,7 @@ class LoginMethodPickerScreen(ModalScreen[str | None]): """Login method picker for the TUI login flow.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel", priority=True), + Binding("escape", "cancel", "Cancel", priority=True), Binding("up", "cursor_up", "Up", show=False, priority=True), Binding("down", "cursor_down", "Down", show=False, priority=True), Binding("enter", "select_cursor", "Select", show=False, priority=True), @@ -992,7 +951,7 @@ class ThemePickerScreen(ModalScreen[TuiThemeName | None]): """Theme picker for the built-in TUI themes.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel", priority=True), + Binding("escape", "cancel", "Cancel", priority=True), Binding("up", "cursor_up", "Up", show=False, priority=True), Binding("down", "cursor_down", "Down", show=False, priority=True), Binding("enter", "select_cursor", "Select", show=False, priority=True), @@ -1067,7 +1026,7 @@ class ModelPickerSearchInput(Input): """Search input that keeps model-picker control keys local to the picker.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel", show=False, priority=True), + Binding("escape", "cancel", "Cancel", show=False, priority=True), Binding("tab", "toggle_mode", "Mode", show=False, priority=True), Binding("ctrl+i", "toggle_mode", "Mode", show=False, priority=True), Binding("up", "cursor_up", "Up", show=False, priority=True), @@ -1091,7 +1050,7 @@ def on_key(self, event: Key) -> None: event.stop() event.prevent_default() self.action_toggle_mode() - elif event.key in {"escape", "ctrl+c"}: + elif event.key == "escape": event.stop() event.prevent_default() self.action_cancel() @@ -1117,7 +1076,7 @@ class ModelPickerScreen(ModalScreen[ModelChoice | None]): """Model picker for the active TUI provider.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel"), + Binding("escape", "cancel", "Cancel"), Binding("tab", "toggle_mode", "Mode", show=False, priority=True), Binding("ctrl+i", "toggle_mode", "Mode", show=False, priority=True), Binding("up", "cursor_up", "Up", show=False), @@ -1333,7 +1292,7 @@ class LoginScreen(ModalScreen[str | None]): """Password prompt for saving a provider API key.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel"), + Binding("escape", "cancel", "Cancel"), ] def __init__(self, provider: ProviderCatalogEntry, *, theme: TuiTheme) -> None: @@ -1369,7 +1328,7 @@ class OAuthLoginScreen(ModalScreen[OAuthCredential | None]): """OAuth login flow for providers backed by subscription auth.""" BINDINGS: ClassVar[list[BindingEntry]] = [ - Binding("escape,ctrl+c", "cancel", "Cancel"), + Binding("escape", "cancel", "Cancel"), ] def __init__(self, provider: ProviderCatalogEntry, *, theme: TuiTheme) -> None: @@ -1381,34 +1340,42 @@ def __init__(self, provider: ProviderCatalogEntry, *, theme: TuiTheme) -> None: def compose(self) -> ComposeResult: """Compose the OAuth login prompt.""" - if self.provider.name == "github-copilot": - help_text = "Starting GitHub device login..." - placeholder = "Waiting for device code" - else: - help_text = "Complete the browser login, or paste the redirect URL." - placeholder = "Paste redirect URL or authorization code" + is_device_flow = self.provider.name == "github-copilot" with Vertical(id="login-screen"): yield Static(f"Login: {self.provider.display_name}", id="login-title") - yield Static(help_text, id="login-help") + yield Static( + ( + "Open the URL and enter the displayed device code." + if is_device_flow + else "Complete the browser login, or paste the redirect URL." + ), + id="login-help", + ) yield Static("", id="login-oauth-url") yield Input( - placeholder=placeholder, + placeholder=( + "Device login is automatic" + if is_device_flow + else "Paste redirect URL or authorization code" + ), id="login-oauth-code", - disabled=self.provider.name == "github-copilot", + disabled=is_device_flow, + ) + yield Static( + "Escape closes" if is_device_flow else "Enter submits - Escape closes", + id="login-footer", ) - yield Static("Enter submits - Escape closes", id="login-footer") def on_mount(self) -> None: """Focus the manual-code field and start OAuth.""" - self.query_one("#login-oauth-code", Input).focus() + if self.provider.name != "github-copilot": + self.query_one("#login-oauth-code", Input).focus() self.run_worker(self._run_login(), exclusive=True) async def _run_login(self) -> None: try: if self.provider.name == "github-copilot": - credential = await login_github_copilot( - on_auth=self._show_auth, - ) + credential = await login_github_copilot(on_auth=self._show_auth) else: credential = await login_openai_codex( on_auth=self._show_auth, @@ -1827,34 +1794,42 @@ def __init__( tui_settings: TuiSettings | None = None, startup_message: str | None = None, startup_notice: str | None = None, + startup_notices: Sequence[str] = (), initial_prompt: str | None = None, ) -> None: self.tui_settings = tui_settings or TuiSettings() self.startup_message = startup_message - self.startup_notice = startup_notice + legacy_notices = (startup_notice,) if startup_notice else () + self.startup_notices = tuple((*startup_notices, *legacy_notices)) self.initial_prompt = initial_prompt super().__init__() self._bindings = BindingsMap(_app_bindings(self.tui_settings.keybindings)) self.session = session self.state = TuiState(skills=session.skills) - if startup_notice: - self.state.add_item("status", startup_notice) + for notice in self.startup_notices: + self.state.add_item("status", notice) self._prompt_history: tuple[str, ...] = () self._load_session_messages_from_session() self.adapter = TuiEventAdapter(self.state) self._prompt_worker: Worker[None] | None = None - self._completion_visible_line_budget: int | None = None self._compaction_worker: Worker[None] | None = None self._prompt_run_id = 0 self._completion_state = CompletionState() + self._completion_visible_line_budget: int | None = None self._activity_frame = 0 self._activity_timer: Timer | None = None self._active_notification_keys: set[tuple[str, str]] = set() self._supports_pyperclip: bool | None = None + self._sync_header_title() + + def _sync_header_title(self) -> None: + """Reflect the active session name in Textual's header state.""" + self.title = "Tau" + self.sub_title = _session_header_sub_title(self.session) def _sync_text_selection_state(self) -> None: """Disable native text selection while the transcript is mutating.""" - self.ALLOW_SELECT = not self.state.running + type(self).ALLOW_SELECT = not self.state.running if self.state.running and self.screen_stack: with suppress(Exception): self.screen.clear_selection() @@ -1952,13 +1927,10 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None: """Update prompt autocomplete when the prompt text changes.""" if event.text_area.id != "prompt": return - prompt = self.query_one("#prompt", PromptInput) - prompt._detect_paste(event.text_area.text) self._sync_prompt_shell_mode(event.text_area.text) self._completion_visible_line_budget = None self._completion_state = self._build_completion_state(event.text_area.text) self._refresh_completions() - self._scroll_transcript_to_bottom() async def action_submit_prompt(self) -> None: """Submit the current prompt text or slash command.""" @@ -1974,11 +1946,6 @@ async def _submit_prompt_from_editor( streaming_behavior: Literal["steer", "follow_up"], ) -> None: prompt = self.query_one("#prompt", PromptInput) - if prompt._pending_full_content is not None: - full = prompt._pending_full_content - extra = prompt.text.removeprefix(prompt._placeholder) - prompt.text = full + extra - prompt._pending_full_content = None raw_text = prompt.text applied_completion = self._apply_selected_completion(raw_text) if applied_completion is not None and applied_completion != raw_text: @@ -2093,12 +2060,10 @@ async def _submit_prompt_from_editor( if self.state.running: self._remember_prompt(text) - self._scroll_transcript_to_bottom() await self._queue_prompt(text, streaming_behavior=streaming_behavior) return self._remember_prompt(text) - self._scroll_transcript_to_bottom() self._submit_prompt(text) def _remember_prompt(self, text: str) -> None: @@ -2159,15 +2124,11 @@ def _submit_prompt(self, text: str) -> None: self._prompt_run_id += 1 run_id = self._prompt_run_id self._follow_transcript_output() - self._refresh(scroll_end=True) + self._refresh() self._prompt_worker = self.run_worker(self._run_prompt(text, run_id), exclusive=True) def _follow_transcript_output(self) -> None: """Put the transcript back in follow mode for explicit user actions.""" - self._scroll_transcript_to_bottom() - - def _scroll_transcript_to_bottom(self) -> None: - """Force the transcript to the newest content immediately after user/agent activity.""" if not self.screen_stack: return with suppress(NoMatches): @@ -2186,7 +2147,7 @@ async def _run_terminal_command(self, command: str, *, add_to_context: bool) -> always_show_tool_result=True, ) self._follow_transcript_output() - self._refresh(scroll_end=True) + self._refresh() try: result = await run_terminal_command(command, add_to_context=add_to_context) @@ -2199,7 +2160,7 @@ async def _run_terminal_command(self, command: str, *, add_to_context: bool) -> output=str(exc), ) self._notify(f"Could not run command: {exc}", severity="error") - self._refresh(scroll_end=True) + self._refresh() return if item_index >= len(self.state.items): @@ -2212,14 +2173,13 @@ async def _run_terminal_command(self, command: str, *, add_to_context: bool) -> output=result.output, ) self._follow_transcript_output() - self._refresh(scroll_end=True) + self._refresh() def _set_tui_theme(self, theme: TuiThemeName) -> None: self.tui_settings = TuiSettings( keybindings=self.tui_settings.keybindings, theme=theme, auto_copy_selection=self.tui_settings.auto_copy_selection, - show_sidebar=self.tui_settings.show_sidebar, ) save_tui_settings(self.tui_settings) self.refresh_css(animate=False) @@ -2238,7 +2198,7 @@ async def _queue_prompt( except Exception as exc: # noqa: BLE001 - surface queueing failures in the TUI self._notify(f"Could not queue message: {exc}", severity="error") return - self._refresh(scroll_end=True) + self._refresh() async def _run_prompt(self, text: str, run_id: int | None = None) -> None: """Run one prompt and stream session events into the TUI state.""" @@ -2252,7 +2212,6 @@ async def _run_prompt(self, text: str, run_id: int | None = None) -> None: if isinstance(event, ErrorEvent) and not event.recoverable: _attach_diagnostic_log_path_to_error(self.state, self.session) await self._apply_streaming_transcript_event(event) - await asyncio.sleep(0) except Exception as exc: # noqa: BLE001 - surface unexpected worker errors in the TUI if active_run_id != self._prompt_run_id: return @@ -2261,7 +2220,7 @@ async def _run_prompt(self, text: str, run_id: int | None = None) -> None: self.state.add_item("error", message) self.state.running = False self._sync_text_selection_state() - self._refresh(scroll_end=True) + self._refresh() finally: if active_run_id == self._prompt_run_id: self._prompt_worker = None @@ -2281,14 +2240,13 @@ async def _apply_streaming_transcript_event(self, event: AgentEvent) -> None: self._refresh_chrome() return if isinstance(event, AgentEndEvent): - await transcript.finish_assistant_message(scroll_end=True) + await transcript.finish_assistant_message() self._refresh_chrome() - self._scroll_transcript_to_bottom() return if isinstance(event, MessageStartEvent): return if isinstance(event, MessageDeltaEvent): - await transcript.append_assistant_delta(event.delta, theme=theme, scroll_end=True) + await transcript.append_assistant_delta(event.delta, theme=theme) self._sync_activity_indicator() return if isinstance(event, ThinkingDeltaEvent): @@ -2296,7 +2254,6 @@ async def _apply_streaming_transcript_event(self, event: AgentEvent) -> None: event.delta, theme=theme, show_thinking=self.state.show_thinking, - scroll_end=True, ) self._sync_activity_indicator() return @@ -2305,34 +2262,31 @@ async def _apply_streaming_transcript_event(self, event: AgentEvent) -> None: self._refresh() return if event.message.role == "assistant": - await transcript.finish_assistant_message(event.message.content, scroll_end=True) + await transcript.finish_assistant_message(event.message.content) self._refresh_chrome() - self._scroll_transcript_to_bottom() return return if isinstance(event, ToolExecutionStartEvent): - await transcript.finish_assistant_message(scroll_end=True) + await transcript.finish_assistant_message() await transcript.append_item( self.state.items[-1], theme=theme, show_tool_results=self.state.show_tool_results, - scroll_end=True, ) self._refresh_chrome() return if isinstance(event, ToolExecutionUpdateEvent | RetryEvent | ErrorEvent): - await transcript.finish_assistant_message(scroll_end=True) + await transcript.finish_assistant_message() if self.state.items: await transcript.append_item( self.state.items[-1], theme=theme, show_tool_results=self.state.show_tool_results, - scroll_end=True, ) self._refresh_chrome() return if isinstance(event, ToolExecutionEndEvent): - self._refresh(scroll_end=True) + self._refresh() return if isinstance(event, QueueUpdateEvent): self._refresh_chrome() @@ -2530,19 +2484,6 @@ def action_toggle_tool_results(self) -> None: self._refresh() self._notify("Tool results expanded." if expanded else "Tool results collapsed.") - def action_toggle_sidebar(self) -> None: - """Toggle the session sidebar and persist the preference.""" - self.tui_settings = TuiSettings( - keybindings=self.tui_settings.keybindings, - theme=self.tui_settings.theme, - auto_copy_selection=self.tui_settings.auto_copy_selection, - show_sidebar=not self.tui_settings.show_sidebar, - ) - save_tui_settings(self.tui_settings) - self._update_responsive_layout(self.size.width, self.size.height) - self._refresh_footer_bindings() - self._notify("Sidebar shown." if self.tui_settings.show_sidebar else "Sidebar hidden.") - def action_toggle_thinking(self) -> None: """Toggle thinking-token display in the transcript.""" self.state.toggle_thinking() @@ -2961,15 +2902,16 @@ def _notify( ) self.notify(message, severity=severity, markup=False) - def _refresh(self, *, scroll_end: bool = False) -> None: + def _refresh(self) -> None: theme = self.tui_settings.resolved_theme self._refresh_chrome(theme=theme) transcript = self.query_one("#transcript", TranscriptView) - transcript.update_from_state(self.state, theme=theme, scroll_end=scroll_end) + transcript.update_from_state(self.state, theme=theme) def _refresh_chrome(self, *, theme: TuiTheme | None = None) -> None: """Refresh non-transcript chrome without remounting transcript blocks.""" theme = theme or self.tui_settings.resolved_theme + self._sync_header_title() self._sync_text_selection_state() self._sync_queue_state() sidebar = self.query_one("#sidebar", SessionSidebar) @@ -3104,8 +3046,7 @@ def _initial_completion_line_budget(self) -> int: ) def _update_responsive_layout(self, width: int, height: int) -> None: - enough_space = width >= SIDEBAR_MIN_WIDTH and height >= SIDEBAR_MIN_HEIGHT - show_sidebar = self.tui_settings.show_sidebar and enough_space + show_sidebar = width >= SIDEBAR_MIN_WIDTH and height >= SIDEBAR_MIN_HEIGHT self.set_class(not show_sidebar, "-hide-sidebar") def _build_completion_state(self, text: str) -> CompletionState: @@ -3433,6 +3374,12 @@ def _named_session_title(title: str | None) -> str | None: return stripped +def _session_header_sub_title(session: CodingSession) -> str: + """Return the session label shown beside Tau in the TUI header.""" + title = _named_session_title(getattr(session, "session_title", None)) + return title or "Untitled session" + + def _login_provider_label(provider: ProviderCatalogEntry) -> str: return f"{provider.display_name}\n {provider.name}" @@ -3634,7 +3581,6 @@ def _app_bindings(keybindings: TuiKeybindings) -> list[Binding]: priority=True, ), Binding(keybindings.toggle_tool_results, "toggle_tool_results", "Tool results"), - Binding(keybindings.toggle_sidebar, "toggle_sidebar", "Sidebar"), Binding(keybindings.toggle_thinking, "toggle_thinking", "Thinking tokens"), Binding(keybindings.copy_message, "clear_prompt", "Clear input"), Binding(keybindings.quit, "quit", "Quit"), @@ -3666,7 +3612,6 @@ def _prompt_bindings( priority=True, ), Binding(keybindings.cancel, "cancel", "Close", priority=True), - Binding("ctrl+c", "cancel", "Close", show=False, priority=True), ] return bindings + _hidden_prompt_bindings(keybindings, visible_bindings=bindings) if mode == "running": @@ -3674,7 +3619,6 @@ def _prompt_bindings( Binding("enter", "submit_prompt", "Steer", priority=True), Binding(keybindings.queue_follow_up, "submit_follow_up", "Follow-up", priority=True), Binding(keybindings.cancel, "cancel", "Cancel", priority=True), - Binding("ctrl+c", "cancel", "Cancel", show=False, priority=True), Binding( keybindings.toggle_thinking, "toggle_thinking", @@ -3687,7 +3631,6 @@ def _prompt_bindings( "Tools", priority=True, ), - Binding(keybindings.toggle_sidebar, "toggle_sidebar", "Sidebar", priority=True), ] return bindings + _hidden_prompt_bindings(keybindings, visible_bindings=bindings) bindings = [ @@ -3697,7 +3640,6 @@ def _prompt_bindings( Binding(keybindings.session_picker, "open_session_picker", "Sessions", priority=True), Binding(keybindings.thinking_cycle, "cycle_thinking", "Thinking", priority=True), Binding(keybindings.model_cycle, "cycle_model", "Model", priority=True), - Binding(keybindings.toggle_sidebar, "toggle_sidebar", "Sidebar", priority=True), Binding( keybindings.copy_message, "clear_prompt", @@ -3714,11 +3656,7 @@ def _hidden_prompt_bindings( *, visible_bindings: Sequence[Binding], ) -> list[Binding]: - visible_keys = { - key.strip() - for binding in visible_bindings - for key in binding.key.split(",") - } + visible_keys = {key for binding in visible_bindings for key in binding.key.split(",")} candidates = ( (keybindings.command_palette, "open_command_palette"), (keybindings.session_picker, "open_session_picker"), @@ -3726,7 +3664,6 @@ def _hidden_prompt_bindings( (keybindings.thinking_cycle, "cycle_thinking"), (keybindings.model_cycle, "cycle_model"), (keybindings.toggle_tool_results, "toggle_tool_results"), - (keybindings.toggle_sidebar, "toggle_sidebar"), (keybindings.toggle_thinking, "toggle_thinking"), (keybindings.copy_message, "clear_prompt"), (keybindings.accept_completion, "accept_completion"), @@ -3892,16 +3829,13 @@ async def run_tui_app( initial_prompt: str | None = None, session_manager: SessionManager | None = None, startup_notice: str | None = None, + startup_notices: Sequence[str] = (), ) -> None: """Create the default provider/session and run the Textual app.""" if new_session and session_id is not None: raise RuntimeError("--resume and --new-session cannot be used together") provider_settings = load_provider_settings() - for provider in provider_settings.providers: - provider_settings = await ensure_dynamic_provider_models( - provider_settings, provider_name=provider.name - ) shell_settings = load_shell_settings() manager = session_manager or SessionManager() record = _explicit_resume_record( @@ -3917,16 +3851,11 @@ async def run_tui_app( ) startup_message: str | None = None runtime_provider_config: ProviderConfig | None = selection.provider - llm_observer = llm_observer_from_env() try: - startup_thinking_level = provider_default_thinking_level( - selection.provider, model=selection.model - ) provider = create_model_provider( selection.provider, model=selection.model, - thinking_level=startup_thinking_level, - llm_observer=llm_observer, + thinking_level=DEFAULT_THINKING_LEVEL, ) except RuntimeError: login_required_message = ( @@ -3961,14 +3890,15 @@ async def run_tui_app( auto_compact_token_threshold=auto_compact_token_threshold, index_on_first_persist=index_on_first_persist, shell_command_prefix=shell_settings.shell_command_prefix, - llm_observer=llm_observer, ) ) + legacy_notices = (startup_notice,) if startup_notice else () + all_startup_notices = tuple((*startup_notices, *legacy_notices)) app = TauTuiApp( session, tui_settings=load_tui_settings(), startup_message=startup_message, - startup_notice=startup_notice, + startup_notices=all_startup_notices, initial_prompt=initial_prompt, ) await app.run_async() diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py index 37d15bca6..84f946c41 100644 --- a/tests/test_provider_runtime.py +++ b/tests/test_provider_runtime.py @@ -18,7 +18,14 @@ def test_create_model_provider_returns_openai_codex_provider(tmp_path) -> None: assert isinstance(provider, OpenAICodexProvider) -def test_create_model_provider_keeps_github_copilot_claude_openai_compatible(tmp_path) -> None: +@pytest.mark.parametrize( + "model", + ["gpt-5.5", "claude-sonnet-5", "gemini-3.5-flash"], +) +def test_create_model_provider_keeps_github_copilot_models_openai_compatible( + tmp_path, + model: str, +) -> None: store = FileCredentialStore(tmp_path / "credentials.json") store.set_oauth( "github-copilot", @@ -34,7 +41,7 @@ def test_create_model_provider_keeps_github_copilot_claude_openai_compatible(tmp provider = create_model_provider( provider_config, credential_store=store, - model="claude-sonnet-5", + model=model, ) assert isinstance(provider, OpenAICompatibleProvider) diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 716af78d8..05eb2efaa 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -1,5 +1,5 @@ from collections.abc import AsyncIterator, Mapping -from json import dumps, loads +from json import loads import httpx import pytest @@ -18,9 +18,6 @@ AnthropicConfig, AnthropicProvider, FakeProvider, - redact_json_value, - redact_headers, - LLMObservation, ModelInfo, OpenAICodexConfig, OpenAICodexCredentials, @@ -43,14 +40,6 @@ async def _collect(stream: AsyncIterator[object]) -> list[object]: return [event async for event in stream] -class RecordingLLMObserver: - def __init__(self) -> None: - self.records: list[LLMObservation] = [] - - def record(self, observation: LLMObservation) -> None: - self.records.append(observation) - - @pytest.mark.anyio async def test_fake_provider_replays_scripted_events() -> None: scripted = [ @@ -530,8 +519,7 @@ def handler(_request: httpx.Request) -> httpx.Response: assert isinstance(events[-1], ProviderErrorEvent) assert events[-1].message == ( - "OpenAI Codex request failed with status 400: " - "The requested model does not exist." + "OpenAI Codex request failed with status 400: The requested model does not exist." ) assert events[-1].data == { "status_code": 400, @@ -1164,13 +1152,9 @@ def handler(request: httpx.Request) -> httpx.Response: UserMessage(content="weather in Paris?"), AssistantMessage( content="", - tool_calls=[ - ToolCall(id="call_1", name="get_weather", arguments={"city": "Paris"}) - ], - ), - ToolResultMessage( - tool_call_id="call_1", name="get_weather", content='{"temp_c": 19}' + tool_calls=[ToolCall(id="call_1", name="get_weather", arguments={"city": "Paris"})], ), + ToolResultMessage(tool_call_id="call_1", name="get_weather", content='{"temp_c": 19}'), UserMessage(content="summarize"), ] @@ -1291,6 +1275,50 @@ def handler(_request: httpx.Request) -> httpx.Response: assert end.finish_reason == "tool_calls" +@pytest.mark.anyio +async def test_responses_api_streams_refusal_as_text() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + text=( + 'data: {"type":"response.refusal.delta","delta":"I can"}\n\n' + 'data: {"type":"response.refusal.delta","delta":"not help with that."}\n\n' + 'data: {"type":"response.refusal.done",' + '"refusal":"I cannot help with that."}\n\n' + 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="gpt-5.5", + system="You are Tau.", + messages=[UserMessage(content="unsafe request")], + tools=[], + ) + ) + + assert [event.type for event in events] == [ + "response_start", + "text_delta", + "text_delta", + "response_end", + ] + text_deltas = [e.delta for e in events if isinstance(e, ProviderTextDeltaEvent)] + assert text_deltas == ["I can", "not help with that."] + end = events[-1] + assert isinstance(end, ProviderResponseEndEvent) + assert end.message.content == "I cannot help with that." + assert end.finish_reason == "stop" + + @pytest.mark.anyio async def test_responses_api_streams_reasoning_summary_as_thinking() -> None: def handler(_request: httpx.Request) -> httpx.Response: @@ -1512,8 +1540,6 @@ def handler(_request: httpx.Request) -> httpx.Response: assert isinstance(end, ProviderResponseEndEvent) assert end.message.content == "partial" assert end.finish_reason == "length" - - @pytest.mark.anyio async def test_list_openai_compatible_models_uses_verbose_and_parses_ids() -> None: requests: list[httpx.Request] = [] @@ -1576,424 +1602,3 @@ def handler(request: httpx.Request) -> httpx.Response: assert [model.id for model in models] == ["model-a"] -@pytest.mark.anyio -async def test_openai_compatible_provider_can_send_responses_reasoning_effort() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - text='data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = OpenAICompatibleProvider( - OpenAICompatibleConfig( - api_key="test-key", - base_url="https://example.test/v1", - reasoning_effort="high", - reasoning_effort_parameter="reasoning.effort", - ), - client=client, - ) - - await _collect( - provider.stream_response( - model="gpt-5.5", - system="You are Tau.", - messages=[UserMessage(content="Say ok")], - tools=[], - ) - ) - - payload = loads(requests[0].content) - assert payload["reasoning"] == {"effort": "high", "summary": "auto"} - assert "reasoning_effort" not in payload - - -@pytest.mark.anyio -async def test_openai_codex_provider_retries_transient_response_failed_event() -> None: - requests: list[httpx.Request] = [] - - async def credentials() -> OpenAICodexCredentials: - return OpenAICodexCredentials(access_token="access-token", account_id="account-1") - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - if len(requests) == 1: - return httpx.Response( - 200, - text=( - 'data: {"type":"response.failed","response":{"status":"failed",' - '"status_code":503,"error":{"code":"service_unavailable",' - '"message":"temporarily unavailable"}}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - return httpx.Response( - 200, - text=( - 'data: {"type":"response.output_text.delta","delta":"ok"}\n\n' - 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = OpenAICodexProvider( - OpenAICodexConfig( - credential_resolver=credentials, - base_url="https://chatgpt.test/backend-api", - max_retries=1, - max_retry_delay_seconds=0, - ), - client=client, - ) - - events = await _collect( - provider.stream_response( - model="gpt-5.5", - system="You are Tau.", - messages=[UserMessage(content="Say ok")], - tools=[], - ) - ) - - assert len(requests) == 2 - assert isinstance(events[1], ProviderRetryEvent) - assert events[1].attempt == 2 - assert events[1].max_attempts == 2 - assert [event.type for event in events] == [ - "response_start", - "retry", - "response_start", - "text_delta", - "response_end", - ] - assert isinstance(events[-1], ProviderResponseEndEvent) - assert events[-1].message.content == "ok" - - -@pytest.mark.anyio -async def test_openai_codex_provider_stops_after_max_response_failed_retries() -> None: - requests: list[httpx.Request] = [] - - async def credentials() -> OpenAICodexCredentials: - return OpenAICodexCredentials(access_token="access-token", account_id="account-1") - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - text=( - 'data: {"type":"response.failed","response":{"status":"failed",' - '"status_code":503,"error":{"message":"temporarily unavailable"}}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = OpenAICodexProvider( - OpenAICodexConfig( - credential_resolver=credentials, - base_url="https://chatgpt.test/backend-api", - max_retries=1, - max_retry_delay_seconds=0, - ), - client=client, - ) - - events = await _collect( - provider.stream_response( - model="gpt-5.5", - system="You are Tau.", - messages=[UserMessage(content="Say ok")], - tools=[], - ) - ) - - assert len(requests) == 2 - assert [event.type for event in events] == [ - "response_start", - "retry", - "response_start", - "error", - ] - assert isinstance(events[-1], ProviderErrorEvent) - assert events[-1].retryable is True - assert events[-1].data == { - "attempts": 2, - "event": { - "type": "response.failed", - "response": { - "status": "failed", - "status_code": 503, - "error": {"message": "temporarily unavailable"}, - }, - } - } - - -@pytest.mark.anyio -async def test_anthropic_provider_retries_transient_stream_error() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - if len(requests) == 1: - return httpx.Response( - 200, - text=( - 'data: {"type":"error","error":{"type":"overloaded_error",' - '"message":"overloaded"}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - return httpx.Response( - 200, - text=( - 'data: {"type":"content_block_delta","index":0,' - '"delta":{"type":"text_delta","text":"ok"}}\n\n' - 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = AnthropicProvider( - AnthropicConfig( - api_key="test-key", - base_url="https://api.anthropic.test/v1", - max_retries=1, - max_retry_delay_seconds=0, - ), - client=client, - ) - - events = await _collect( - provider.stream_response( - model="claude-test", - system="You are Tau.", - messages=[UserMessage(content="Say ok")], - tools=[], - ) - ) - - assert len(requests) == 2 - assert isinstance(events[1], ProviderRetryEvent) - assert [event.type for event in events] == [ - "response_start", - "retry", - "response_start", - "text_delta", - "response_end", - ] - - -@pytest.mark.anyio -async def test_anthropic_provider_does_not_retry_non_transient_stream_error() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - text=( - 'data: {"type":"error","error":{"type":"invalid_request_error",' - '"message":"bad request"}}\n\n' - ), - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = AnthropicProvider( - AnthropicConfig( - api_key="test-key", - base_url="https://api.anthropic.test/v1", - max_retries=3, - max_retry_delay_seconds=0, - ), - client=client, - ) - - events = await _collect( - provider.stream_response( - model="claude-test", - system="You are Tau.", - messages=[UserMessage(content="Say ok")], - tools=[], - ) - ) - - assert len(requests) == 1 - assert [event.type for event in events] == ["response_start", "error"] - assert isinstance(events[-1], ProviderErrorEvent) - assert events[-1].retryable is False - assert events[-1].data == { - "attempts": 1, - "event": { - "type": "error", - "error": {"type": "invalid_request_error", "message": "bad request"}, - }, - } - - -def test_llm_observation_redacts_headers_and_prompt_like_text() -> None: - headers = redact_headers( - { - "Authorization": "Bearer secret-key", - "X-Api-Key": "api-secret", - "X-HF-Bill-To": "my-org", - } - ) - - assert headers["Authorization"] == "[REDACTED]" - assert headers["X-Api-Key"] == "[REDACTED]" - assert headers["X-HF-Bill-To"] == "my-org" - - body = redact_json_value( - { - "model": "test-model", - "messages": [{"role": "user", "content": "secret prompt"}], - "input": {"type": "function_call", "path": "/private/file.txt"}, - } - ) - - assert isinstance(body, dict) - assert body["model"] == "test-model" - messages = body["messages"] - assert isinstance(messages, list) - message = messages[0] - assert isinstance(message, dict) - assert message["role"] == "user" - content = message["content"] - assert isinstance(content, dict) - assert content["redacted"] is True - assert content["length"] == len("secret prompt") - input_value = body["input"] - assert isinstance(input_value, dict) - assert input_value["type"] == "function_call" - path = input_value["path"] - assert isinstance(path, dict) - assert path["redacted"] is True - - -@pytest.mark.anyio -async def test_openai_compatible_provider_observes_redacted_request_and_response() -> None: - observer = RecordingLLMObserver() - - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - text='data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\n', - headers={"content-type": "text/event-stream", "x-request-id": "req-1"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = OpenAICompatibleProvider( - OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), - client=client, - observer=observer, - ) - - await _collect( - provider.stream_response( - model="test-model", - system="system secret", - messages=[UserMessage(content="user secret")], - tools=[], - ) - ) - - assert [record.kind for record in observer.records] == ["request", "response"] - request = observer.records[0] - assert request.provider == "openai-compatible" - assert request.model == "test-model" - assert request.method == "POST" - assert request.url == "https://example.test/v1/chat/completions" - assert request.attempt == 1 - assert request.stream is True - assert request.data["request"]["headers"]["Authorization"] == "[REDACTED]" # type: ignore[index] - request_body = request.data["request"]["body"] # type: ignore[index] - assert request_body["model"] == "test-model" # type: ignore[index] - assert request_body["stream"] is True # type: ignore[index] - system_content = request_body["messages"][0]["content"] # type: ignore[index] - user_content = request_body["messages"][1]["content"] # type: ignore[index] - assert system_content["redacted"] is True # type: ignore[index] - assert system_content["length"] == len("system secret") # type: ignore[index] - assert user_content["redacted"] is True # type: ignore[index] - assert "system secret" not in dumps(request.to_json()) - assert "user secret" not in dumps(request.to_json()) - assert "test-key" not in dumps(request.to_json()) - - response = observer.records[1] - assert response.data["response"]["status_code"] == 200 # type: ignore[index] - assert response.data["response"]["headers"]["x-request-id"] == "req-1" # type: ignore[index] - - -@pytest.mark.anyio -async def test_openai_compatible_provider_observes_redacted_error_body() -> None: - observer = RecordingLLMObserver() - - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(400, text="bad request includes user secret") - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = OpenAICompatibleProvider( - OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), - client=client, - observer=observer, - ) - - events = await _collect( - provider.stream_response( - model="test-model", - system="You are Tau.", - messages=[UserMessage(content="Say hello")], - tools=[], - ) - ) - - assert isinstance(events[-1], ProviderErrorEvent) - assert [record.kind for record in observer.records] == ["request", "response", "error"] - error = observer.records[-1] - error_body = error.data["error"]["body"] # type: ignore[index] - assert error_body["redacted"] is True # type: ignore[index] - assert error_body["length"] == len("bad request includes user secret") # type: ignore[index] - assert "bad request includes user secret" not in dumps(error.to_json()) - - -@pytest.mark.anyio -async def test_anthropic_provider_can_use_bearer_auth_for_copilot() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - text='data: {"type":"message_stop"}\n\n', - headers={"content-type": "text/event-stream"}, - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = AnthropicProvider( - AnthropicConfig( - api_key="copilot-token", - base_url="https://api.individual.githubcopilot.com", - auth_header="authorization", - ), - client=client, - ) - async for _event in provider.stream_response( - model="claude-sonnet-4.6", - system="", - messages=[UserMessage(content="hello")], - tools=[], - ): - pass - - request = requests[0] - assert request.headers["authorization"] == "Bearer copilot-token" - assert "x-api-key" not in request.headers diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index a486c11cc..741930058 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -33,7 +33,6 @@ ) from tau_coding.commands import CommandResult from tau_coding.credentials import FileCredentialStore, OAuthCredential -from tau_coding.oauth import OAuthAuthInfo from tau_coding.prompt_templates import PromptTemplate from tau_coding.provider_config import ( OpenAICodexProviderConfig, @@ -3482,60 +3481,6 @@ async def fake_login_openai_codex(**_kwargs: object) -> OAuthCredential: assert "refresh-token" in credentials -@pytest.mark.anyio -async def test_tui_login_github_copilot_starts_device_flow_without_enterprise_prompt( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("HOME", str(tmp_path)) - credential_future = asyncio.get_running_loop().create_future() - login_kwargs: dict[str, object] = {} - - async def fake_login_github_copilot(**kwargs: object) -> OAuthCredential: - login_kwargs.update(kwargs) - on_auth = kwargs["on_auth"] - assert callable(on_auth) - on_auth( - OAuthAuthInfo( - url="https://github.com/login/device", - instructions="Enter code: ABCD-1234", - ) - ) - return await credential_future - - monkeypatch.setattr(tui_app, "login_github_copilot", fake_login_github_copilot) - session = FakeSession() - app = TauTuiApp(session) - - async with app.run_test() as pilot: - prompt = app.query_one("#prompt") - prompt.value = "/login github-copilot" - await pilot.press("enter") - await pilot.pause() - - assert isinstance(app.screen, OAuthLoginScreen) - assert "on_prompt" not in login_kwargs - assert str(app.screen.query_one("#login-oauth-url", Static).render()) == ( - "https://github.com/login/device" - ) - assert str(app.screen.query_one("#login-help", Static).render()) == "Enter code: ABCD-1234" - assert app.screen.query_one("#login-oauth-code", Input).disabled is True - credential_future.set_result( - OAuthCredential( - access="copilot-token", - refresh="github-token", - expires=123456, - account_id="github.com", - ) - ) - await pilot.pause() - - assert session.provider_name == "github-copilot" - credentials = (tmp_path / ".tau" / "credentials.json").read_text(encoding="utf-8") - assert '"type": "oauth"' in credentials - assert "github-token" in credentials - - @pytest.mark.anyio async def test_tui_login_preserves_existing_scoped_models_and_providers( monkeypatch: pytest.MonkeyPatch, @@ -3810,6 +3755,7 @@ async def test_tui_login_api_key_opens_api_provider_picker() -> None: labels = [str(item.query_one(Label).render()) for item in provider_list.children] assert labels[0] == "OpenAI\n openai" assert "OpenAI Codex subscription\n openai-codex" not in labels + assert "GitHub Copilot\n github-copilot" not in labels await pilot.press("down") await pilot.press("enter") @@ -5152,11 +5098,3 @@ def __init__(self, records: list[CodingSessionRecord]) -> None: def list_sessions(self, cwd: Path | None = None) -> list[CodingSessionRecord]: del cwd return self._records - - -def test_github_copilot_is_subscription_login_provider() -> None: - subscription = tui_app._subscription_login_providers(tui_app.BUILTIN_PROVIDER_CATALOG) - api_key = tui_app._api_key_login_providers(tui_app.BUILTIN_PROVIDER_CATALOG) - - assert any(provider.name == "github-copilot" for provider in subscription) - assert all(provider.name != "github-copilot" for provider in api_key) From 90a13bfe162e20391e390d5426d9a95204f471d0 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Sat, 4 Jul 2026 13:40:32 +0100 Subject: [PATCH 05/10] Update provider catalog test expectations --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index dff36197e..983858df7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -790,7 +790,7 @@ def test_providers_command_lists_default_provider( assert result.exit_code == 0 assert "*\topenai\topenai-compatible\tgpt-5.5" in result.stdout assert " \topenai-codex\topenai-codex\tgpt-5.5" in result.stdout - assert " \tanthropic\tanthropic\tclaude-sonnet-4-6" in result.stdout + assert " \tanthropic\tanthropic\tclaude-sonnet-5" in result.stdout assert " \topenrouter\topenai-compatible\topenai/gpt-5.5" in result.stdout assert " \thuggingface\topenai-compatible\topenai/gpt-oss-120b" in result.stdout From f1dec143607c40574c3fc1c6ff6c22103b973ef7 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Sat, 4 Jul 2026 13:51:07 +0100 Subject: [PATCH 06/10] Keep upstream Copilot catalog public-safe --- src/tau_coding/provider_catalog.py | 11 ++--------- tests/test_cli.py | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/tau_coding/provider_catalog.py b/src/tau_coding/provider_catalog.py index 62779554d..699d75d25 100644 --- a/src/tau_coding/provider_catalog.py +++ b/src/tau_coding/provider_catalog.py @@ -147,25 +147,19 @@ class ProviderCatalogEntry: api_key_env="ANTHROPIC_API_KEY", credential_name="anthropic", models=( - "claude-fable-5", - "claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", ), - default_model="claude-sonnet-5", + default_model="claude-sonnet-4-6", docs_url="https://docs.anthropic.com", context_windows={ - "claude-fable-5": 1_000_000, - "claude-sonnet-5": 1_000_000, "claude-sonnet-4-6": 1_000_000, - "claude-opus-4-8": 1_000_000, + "claude-opus-4-8": 200_000, "claude-haiku-4-5": 200_000, }, thinking_levels=("off", "minimal", "low", "medium", "high", "xhigh"), thinking_models=( - "claude-fable-5", - "claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8", ), @@ -196,7 +190,6 @@ class ProviderCatalogEntry: "gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", - "mai-code-1-flash-picker", ), default_model="gpt-5.5", docs_url="https://docs.github.com/copilot", diff --git a/tests/test_cli.py b/tests/test_cli.py index 983858df7..dff36197e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -790,7 +790,7 @@ def test_providers_command_lists_default_provider( assert result.exit_code == 0 assert "*\topenai\topenai-compatible\tgpt-5.5" in result.stdout assert " \topenai-codex\topenai-codex\tgpt-5.5" in result.stdout - assert " \tanthropic\tanthropic\tclaude-sonnet-5" in result.stdout + assert " \tanthropic\tanthropic\tclaude-sonnet-4-6" in result.stdout assert " \topenrouter\topenai-compatible\topenai/gpt-5.5" in result.stdout assert " \thuggingface\topenai-compatible\topenai/gpt-oss-120b" in result.stdout From 718a2823e936f5a66b57d2b98932b546d8a38b34 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Sat, 4 Jul 2026 23:27:40 +0100 Subject: [PATCH 07/10] Fix provider tool-call sanitization --- src/tau_ai/anthropic.py | 45 ++++++++++- src/tau_ai/openai_codex.py | 29 +++++++- src/tau_ai/openai_compatible.py | 27 +++++-- src/tau_coding/provider_runtime.py | 89 +++++++++++++++++++++- tests/test_provider_runtime.py | 71 ++++++++++++++++-- tests/test_tau_ai.py | 115 +++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 16 deletions(-) diff --git a/src/tau_ai/anthropic.py b/src/tau_ai/anthropic.py index 53dfe021f..2735774f9 100644 --- a/src/tau_ai/anthropic.py +++ b/src/tau_ai/anthropic.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Mapping +from hashlib import sha1 from json import loads from typing import Any @@ -67,6 +68,7 @@ async def iterator() -> AsyncIterator[ProviderEvent]: tools=tools, thinking_budget_tokens=self._config.thinking_budget_tokens, ) + _sanitize_anthropic_payload_tool_ids(payload) headers = { **(dict(self._config.headers or {})), "anthropic-version": ANTHROPIC_VERSION, @@ -296,7 +298,7 @@ def _anthropic_message(message: AgentMessage) -> dict[str, JSONValue]: content.append( { "type": "tool_use", - "id": tool_call.id, + "id": _anthropic_tool_id(tool_call.id), "name": tool_call.name, "input": tool_call.arguments, } @@ -308,7 +310,7 @@ def _anthropic_message(message: AgentMessage) -> dict[str, JSONValue]: "content": [ { "type": "tool_result", - "tool_use_id": message.tool_call_id, + "tool_use_id": _anthropic_tool_id(message.tool_call_id), "content": message.content, "is_error": not message.ok, } @@ -317,6 +319,45 @@ def _anthropic_message(message: AgentMessage) -> dict[str, JSONValue]: raise TypeError(f"Unsupported message type: {type(message).__name__}") +def _anthropic_tool_id(value: str) -> str: + """Return a tool-use id accepted by Anthropic's Messages API.""" + cleaned = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in value) + cleaned = cleaned.strip("_") or "tool_call" + if len(cleaned) <= 64: + return cleaned + suffix = "_" + sha1(value.encode("utf-8")).hexdigest()[:10] + return (cleaned[: 64 - len(suffix)].rstrip("_") or "tool_call") + suffix + + + +def _sanitize_anthropic_payload_tool_ids(payload: dict[str, JSONValue]) -> None: + """Defensively sanitize tool IDs in the final Anthropic payload.""" + messages = payload.get("messages") + if not isinstance(messages, list): + return + id_map: dict[str, str] = {} + for message in messages: + if not isinstance(message, dict): + continue + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_use": + raw = block.get("id") + if isinstance(raw, str): + clean = _anthropic_tool_id(raw) + id_map[raw] = clean + block["id"] = clean + elif block.get("type") == "tool_result": + raw = block.get("tool_use_id") + if isinstance(raw, str): + block["tool_use_id"] = id_map.get(raw, _anthropic_tool_id(raw)) + + + def _anthropic_tool(tool: AgentTool) -> dict[str, JSONValue]: return { "name": tool.name, diff --git a/src/tau_ai/openai_codex.py b/src/tau_ai/openai_codex.py index a01df3e80..2b6439f11 100644 --- a/src/tau_ai/openai_codex.py +++ b/src/tau_ai/openai_codex.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from dataclasses import dataclass +from hashlib import sha1 from json import JSONDecodeError, dumps, loads from platform import machine, release, system from typing import Any @@ -318,15 +319,16 @@ def _messages_to_responses_input(messages: list[AgentMessage]) -> list[JSONValue call_id, item_id = _split_tool_call_id(tool_call.id) item: dict[str, JSONValue] = { "type": "function_call", - "call_id": call_id, - "name": tool_call.name, + "call_id": _codex_call_id(call_id), + "name": tool_call.name or "tool", "arguments": dumps(tool_call.arguments), } if item_id: - item["id"] = item_id + item["id"] = _codex_item_id(item_id) items.append(item) elif isinstance(message, ToolResultMessage): call_id, _item_id = _split_tool_call_id(message.tool_call_id) + call_id = _codex_call_id(call_id) items.append( { "type": "function_call_output", @@ -337,6 +339,27 @@ def _messages_to_responses_input(messages: list[AgentMessage]) -> list[JSONValue return items +def _codex_identifier(value: str, *, fallback: str) -> str: + """Return a Codex Responses identifier safe for replayed transcript items.""" + cleaned = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in value) + cleaned = cleaned.strip("_") or fallback + if len(cleaned) <= 64: + return cleaned + suffix = "_" + sha1(value.encode("utf-8")).hexdigest()[:10] + return (cleaned[: 64 - len(suffix)].rstrip("_") or fallback) + suffix + + + +def _codex_call_id(value: str) -> str: + return _codex_identifier(value, fallback="call") + + + +def _codex_item_id(value: str) -> str: + return _codex_identifier(value, fallback="item") + + + def _tool_to_codex(tool: AgentTool) -> dict[str, JSONValue]: return { "type": "function", diff --git a/src/tau_ai/openai_compatible.py b/src/tau_ai/openai_compatible.py index 4b60a3634..e5414b341 100644 --- a/src/tau_ai/openai_compatible.py +++ b/src/tau_ai/openai_compatible.py @@ -11,6 +11,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Mapping +from hashlib import sha1 from json import JSONDecodeError, dumps, loads from typing import Any, Protocol @@ -625,8 +626,8 @@ def _messages_to_responses_input( items.append( { "type": "function_call", - "call_id": tool_call.id, - "name": tool_call.name, + "call_id": _responses_call_id(tool_call.id), + "name": tool_call.name or "tool", "arguments": dumps(tool_call.arguments), } ) @@ -634,13 +635,29 @@ def _messages_to_responses_input( items.append( { "type": "function_call_output", - "call_id": message.tool_call_id, + "call_id": _responses_call_id(message.tool_call_id), "output": message.content, } ) return items +def _responses_call_id(value: str) -> str: + """Return a Responses API call_id accepted by OpenAI-compatible backends. + + Provider transcripts can persist foreign tool-call IDs with separators or + long opaque suffixes. Normalize separators and add a short hash suffix when + truncating so function_call/function_call_output pairs remain stable. + """ + cleaned = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in value) + cleaned = cleaned.strip("_") or "call" + if len(cleaned) <= 64: + return cleaned + suffix = "_" + sha1(value.encode("utf-8")).hexdigest()[:10] + return (cleaned[: 64 - len(suffix)].rstrip("_") or "call") + suffix + + + def _tool_to_responses(tool: AgentTool) -> dict[str, JSONValue]: return { "type": "function", @@ -769,7 +786,7 @@ def _message_to_openai(message: AgentMessage) -> dict[str, JSONValue]: return { "role": "tool", "tool_call_id": message.tool_call_id, - "name": message.name, + "name": message.name or "tool", "content": message.content, } @@ -790,7 +807,7 @@ def _tool_call_to_openai(tool_call: ToolCall) -> dict[str, JSONValue]: "id": tool_call.id, "type": "function", "function": { - "name": tool_call.name, + "name": tool_call.name or "tool", "arguments": dumps(tool_call.arguments), }, } diff --git a/src/tau_coding/provider_runtime.py b/src/tau_coding/provider_runtime.py index 5b1de0542..528dbc9e1 100644 --- a/src/tau_coding/provider_runtime.py +++ b/src/tau_coding/provider_runtime.py @@ -3,17 +3,22 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterator from dataclasses import replace from os import environ from typing import Protocol +from tau_agent.messages import AgentMessage +from tau_agent.tools import AgentTool from tau_ai import ( AnthropicProvider, + CancellationToken, ModelProvider, OpenAICodexConfig, OpenAICodexCredentials, OpenAICodexProvider, OpenAICompatibleProvider, + ProviderEvent, ) from tau_coding.credentials import FileCredentialStore, OAuthCredential from tau_coding.oauth import ( @@ -55,7 +60,11 @@ def create_model_provider( credentials = credential_store or FileCredentialStore() selected_model = model or provider.default_model if provider.name == "github-copilot": - provider = _github_copilot_provider_config(provider, credential_store=credentials) + return GitHubCopilotCredentialRefreshingProvider( + provider, + credential_store=credentials, + thinking_level=thinking_level, + ) override = catalog_model_override(provider.name, selected_model) if ( provider.name != "github-copilot" @@ -158,6 +167,84 @@ def _github_copilot_headers() -> dict[str, str]: } +class GitHubCopilotCredentialRefreshingProvider: + """GitHub Copilot provider wrapper with request-time OAuth refresh.""" + + def __init__( + self, + provider: ProviderConfig, + *, + credential_store: FileCredentialStore, + thinking_level: ThinkingLevel | None = None, + ) -> None: + self._provider = provider + self._credential_store = credential_store + self._thinking_level = thinking_level + + async def aclose(self) -> None: + """No persistent HTTP client is owned by the wrapper.""" + + def stream_response( + self, + *, + model: str, + system: str, + messages: list[AgentMessage], + tools: list[AgentTool], + signal: CancellationToken | None = None, + ) -> AsyncIterator[ProviderEvent]: + """Refresh Copilot credentials, then delegate one streamed request.""" + + async def iterator() -> AsyncIterator[ProviderEvent]: + inner = await self._fresh_inner_provider(model=model) + try: + async for event in inner.stream_response( + model=model, + system=system, + messages=messages, + tools=tools, + signal=signal, + ): + yield event + finally: + await inner.aclose() + + return iterator() + + async def _fresh_inner_provider(self, *, model: str) -> ClosableModelProvider: + provider = await self._fresh_provider_config() + return OpenAICompatibleProvider( + openai_compatible_config_from_provider( + provider, + credential_reader=self._credential_store, + model=model, + thinking_level=self._thinking_level, + ) + ) + + async def _fresh_provider_config(self) -> ProviderConfig: + headers = {**dict(self._provider.headers), **_github_copilot_headers()} + credential_name = self._provider.credential_name + if not credential_name: + return replace(self._provider, headers=headers) + + credential = self._credential_store.get_oauth(credential_name) + if credential is None: + return replace(self._provider, headers=headers) + if oauth_credential_is_expired(credential): + credential = await refresh_github_copilot_token( + credential.refresh, + enterprise_domain=credential.account_id if credential.account_id != "github.com" else "", + ) + self._credential_store.set_oauth(credential_name, credential) + return replace( + self._provider, + base_url=github_copilot_base_url(credential.access, credential.account_id), + headers=headers, + ) + + + def _anthropic_provider_config_for_model( provider: ProviderConfig, model: str, diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py index 84f946c41..3f6ed50af 100644 --- a/tests/test_provider_runtime.py +++ b/tests/test_provider_runtime.py @@ -4,7 +4,11 @@ from tau_coding import provider_runtime from tau_coding.credentials import FileCredentialStore, OAuthCredential from tau_coding.provider_config import OpenAICodexProviderConfig, ProviderSettings -from tau_coding.provider_runtime import OpenAICodexCredentialResolver, create_model_provider +from tau_coding.provider_runtime import ( + GitHubCopilotCredentialRefreshingProvider, + OpenAICodexCredentialResolver, + create_model_provider, +) def test_create_model_provider_returns_openai_codex_provider(tmp_path) -> None: @@ -18,11 +22,12 @@ def test_create_model_provider_returns_openai_codex_provider(tmp_path) -> None: assert isinstance(provider, OpenAICodexProvider) +@pytest.mark.anyio @pytest.mark.parametrize( "model", ["gpt-5.5", "claude-sonnet-5", "gemini-3.5-flash"], ) -def test_create_model_provider_keeps_github_copilot_models_openai_compatible( +async def test_create_model_provider_keeps_github_copilot_models_openai_compatible( tmp_path, model: str, ) -> None: @@ -44,9 +49,14 @@ def test_create_model_provider_keeps_github_copilot_models_openai_compatible( model=model, ) - assert isinstance(provider, OpenAICompatibleProvider) - assert provider._config.base_url == "https://api.enterprise.test" - assert provider._config.headers["Copilot-Integration-Id"] == "vscode-chat" + assert isinstance(provider, GitHubCopilotCredentialRefreshingProvider) + inner = await provider._fresh_inner_provider(model=model) + try: + assert isinstance(inner, OpenAICompatibleProvider) + assert inner._config.base_url == "https://api.enterprise.test" + assert inner._config.headers["Copilot-Integration-Id"] == "vscode-chat" + finally: + await inner.aclose() def test_create_model_provider_maps_codex_reasoning_effort_like_pi(tmp_path) -> None: @@ -84,6 +94,57 @@ def test_create_model_provider_maps_codex_reasoning_effort_like_pi(tmp_path) -> assert xhigh_provider._config.reasoning_effort == "xhigh" +@pytest.mark.anyio +async def test_github_copilot_provider_refreshes_expired_credentials_per_request( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_oauth( + "github-copilot", + OAuthCredential( + access="old-token", + refresh="github-refresh", + expires=1, + account_id="github.com", + ), + ) + provider_config = ProviderSettings().get_provider("github-copilot") + + async def fake_refresh(refresh_token: str, *, enterprise_domain: str = "") -> OAuthCredential: + assert refresh_token == "github-refresh" + assert enterprise_domain == "" + return OAuthCredential( + access="tid=1;proxy-ep=proxy.enterprise.test;new-token", + refresh="github-refresh", + expires=9999999999999, + account_id="github.com", + ) + + monkeypatch.setattr(provider_runtime, "refresh_github_copilot_token", fake_refresh) + + provider = create_model_provider( + provider_config, + credential_store=store, + model="claude-sonnet-5", + ) + + assert isinstance(provider, GitHubCopilotCredentialRefreshingProvider) + inner = await provider._fresh_inner_provider(model="claude-sonnet-5") + try: + assert isinstance(inner, OpenAICompatibleProvider) + assert inner._config.api_key == "tid=1;proxy-ep=proxy.enterprise.test;new-token" + assert inner._config.base_url == "https://api.enterprise.test" + finally: + await inner.aclose() + assert store.get_oauth("github-copilot") == OAuthCredential( + access="tid=1;proxy-ep=proxy.enterprise.test;new-token", + refresh="github-refresh", + expires=9999999999999, + account_id="github.com", + ) + + @pytest.mark.anyio async def test_openai_codex_credential_resolver_refreshes_expired_credentials( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 05eb2efaa..7d24119aa 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -1602,3 +1602,118 @@ def handler(request: httpx.Request) -> httpx.Response: assert [model.id for model in models] == ["model-a"] + + +def test_openai_responses_payload_sanitizes_foreign_tool_ids() -> None: + from tau_ai.openai_compatible import _messages_to_responses_input + + foreign_id = "call_" + ("x" * 90) + "|fc.bad/id:with:separators" + items = _messages_to_responses_input( + [ + AssistantMessage( + content="", + tool_calls=[ToolCall(id=foreign_id, name="read", arguments={"path": "x"})], + ), + ToolResultMessage(tool_call_id=foreign_id, name="read", content="ok", ok=True), + ] + ) + + assert items[0]["type"] == "function_call" + assert items[0]["name"] == "read" + assert isinstance(items[0]["call_id"], str) + assert len(items[0]["call_id"]) <= 64 + assert "|" not in items[0]["call_id"] + assert items[1]["call_id"] == items[0]["call_id"] + + +def test_openai_codex_payload_sanitizes_foreign_tool_ids_and_empty_names() -> None: + from tau_ai.openai_codex import _build_codex_payload + + foreign_id = "call_" + ("x" * 90) + "|fc.bad/id:with:separators" + payload = _build_codex_payload( + model="gpt-5.5", + system="system", + messages=[ + AssistantMessage( + content="", + tool_calls=[ToolCall(id=foreign_id, name="", arguments={"path": "x"})], + ), + ToolResultMessage(tool_call_id=foreign_id, name="", content="ok", ok=True), + ], + tools=[], + ) + items = payload["input"] + + assert items[0]["type"] == "function_call" + assert items[0]["name"] == "tool" + assert isinstance(items[0]["call_id"], str) + assert len(items[0]["call_id"]) <= 64 + assert "|" not in items[0]["call_id"] + assert items[1]["call_id"] == items[0]["call_id"] + + +def test_anthropic_messages_payload_sanitizes_foreign_tool_ids() -> None: + from tau_ai.anthropic import _build_messages_payload + + foreign_id = "call_" + ("x" * 90) + "|fc.bad/id:with:separators" + payload = _build_messages_payload( + model="claude-test", + system="system", + messages=[ + AssistantMessage( + content="", + tool_calls=[ToolCall(id=foreign_id, name="read", arguments={"path": "x"})], + ), + ToolResultMessage(tool_call_id=foreign_id, name="read", content="ok", ok=True), + ], + tools=[], + thinking_budget_tokens=None, + ) + + tool_use_id = payload["messages"][0]["content"][0]["id"] + tool_result_id = payload["messages"][1]["content"][0]["tool_use_id"] + assert tool_result_id == tool_use_id + assert len(tool_use_id) <= 64 + assert "|" not in tool_use_id + assert "." not in tool_use_id + assert "/" not in tool_use_id + assert ":" not in tool_use_id + + +@pytest.mark.anyio +async def test_openai_chat_completions_replays_empty_tool_names_as_tool() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text='data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\n', + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig(api_key="test-key", base_url="https://example.test/v1"), + client=client, + ) + await _collect( + provider.stream_response( + model="gpt-5.1", + system="system", + messages=[ + AssistantMessage( + content="", + tool_calls=[ToolCall(id="call_1", name="", arguments={})], + ), + ToolResultMessage(tool_call_id="call_1", name="", content="ok", ok=True), + ], + tools=[], + ) + ) + + payload = loads(requests[0].content) + assistant = next(message for message in payload["messages"] if message["role"] == "assistant") + tool = next(message for message in payload["messages"] if message["role"] == "tool") + assert assistant["tool_calls"][0]["function"]["name"] == "tool" + assert tool["name"] == "tool" From 0f783920286be0c60c34e681e768677d0be4546f Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Sat, 4 Jul 2026 23:52:04 +0100 Subject: [PATCH 08/10] Fix restored Copilot provider routing --- src/tau_agent/loop.py | 22 ++++++++++- src/tau_coding/session.py | 40 ++++++++++++++++++- tests/test_agent_harness.py | 54 ++++++++++++++++++++++++++ tests/test_coding_session.py | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/src/tau_agent/loop.py b/src/tau_agent/loop.py index e7681fcb3..60e03c723 100644 --- a/src/tau_agent/loop.py +++ b/src/tau_agent/loop.py @@ -231,10 +231,30 @@ async def _execute_tool( ) if result.tool_call_id != tool_call.id: - return result.model_copy(update={"tool_call_id": tool_call.id}) + return _replace_tool_call_id(result, tool_call.id) return result +def _replace_tool_call_id(result: AgentToolResult, tool_call_id: str) -> AgentToolResult: + """Return a copy of a tool result with a provider-issued tool call id. + + Tau runs on constrained Python environments that may expose Pydantic v1-style + models or local shims. Prefer Pydantic v2's ``model_copy`` when present, but + fall back to constructing a new result from the public fields. + """ + model_copy = getattr(result, "model_copy", None) + if callable(model_copy): + return model_copy(update={"tool_call_id": tool_call_id}) + return AgentToolResult( + tool_call_id=tool_call_id, + name=result.name, + ok=result.ok, + content=result.content, + error=result.error, + data=result.data, + ) + + def _unknown_tool_result(tool_call: ToolCall) -> AgentToolResult: message = f"Unknown tool: {tool_call.name}" return AgentToolResult( diff --git a/src/tau_coding/session.py b/src/tau_coding/session.py index 110f0669a..1a2d5c71b 100644 --- a/src/tau_coding/session.py +++ b/src/tau_coding/session.py @@ -912,6 +912,7 @@ async def resume(self, session_id: str) -> str: provider_name = self._provider_name runtime_provider_config = self._runtime_provider_config + restored_model = record.model or self.model if record.provider_name: if self._provider_settings is None: raise ValueError( @@ -925,11 +926,20 @@ async def resume(self, session_id: str) -> str: f"Session provider is not configured: {record.provider_name}" ) from exc provider_name = runtime_provider_config.name + else: + inferred = _infer_provider_for_model( + self._provider_settings, + restored_model, + current_provider_name=self._provider_name, + ) + if inferred is not None: + provider_name = inferred.name + runtime_provider_config = inferred replacement = await type(self).load( CodingSessionConfig( provider=self._harness.config.provider, - model=record.model or self.model, + model=restored_model, cwd=record.cwd, storage=jsonl_session_storage(record.path), system=self._config.system, @@ -1733,6 +1743,34 @@ def _session_export_title(session: CodingSession) -> str: return f"Tau session {session_id}" if session_id is not None else "Tau Session Export" +def _infer_provider_for_model( + provider_settings: ProviderSettings | None, + model: str, + *, + current_provider_name: str, +) -> ProviderConfig | None: + """Infer the provider for a restored session model when index metadata is stale. + + Older session indexes did not always persist ``provider_name``. Resuming one + of those sessions after using another provider can otherwise combine the + restored model with the current runtime provider (for example + ``openai-codex:claude-opus-4.8``), which fails before the user can recover. + Only infer when provider settings identify a unique configured provider for + the model, and keep the current provider when it already advertises it. + """ + if provider_settings is None or not model: + return None + matches = tuple(provider for provider in provider_settings.providers if model in provider.models) + if not matches: + return None + for provider in matches: + if provider.name == current_provider_name: + return provider + if len(matches) == 1: + return matches[0] + return None + + def _state_thinking_level( state: SessionState, default: ThinkingLevel, diff --git a/tests/test_agent_harness.py b/tests/test_agent_harness.py index 9d4360411..006df61ba 100644 --- a/tests/test_agent_harness.py +++ b/tests/test_agent_harness.py @@ -198,6 +198,60 @@ async def test_cancel_requests_cancellation_for_current_run() -> None: assert harness.messages == (UserMessage(content="Hi"),) +@pytest.mark.anyio +async def test_tool_result_id_rewrite_works_without_model_copy() -> None: + class LegacyToolResult: + tool_call_id = "wrong-id" + name = "read" + ok = True + content = "ok" + error = None + data = {"path": "README.md"} + + async def executor( + arguments: Mapping[str, JSONValue], + signal: object | None = None, + ) -> AgentToolResult: + del arguments, signal + return LegacyToolResult() # type: ignore[return-value] + + tool = AgentTool( + name="read", + description="Read a file.", + input_schema={"type": "object"}, + executor=executor, + ) + tool_call = ToolCall(id="call-1", name="read", arguments={"path": "README.md"}) + provider = FakeProvider( + [ + [ + ProviderResponseStartEvent(model="fake"), + ProviderResponseEndEvent( + message=AssistantMessage(content="I'll read it.", tool_calls=[tool_call]) + ), + ], + [ + ProviderResponseStartEvent(model="fake"), + ProviderResponseEndEvent(message=AssistantMessage(content="Done.")), + ], + ] + ) + harness = AgentHarness( + AgentHarnessConfig( + provider=provider, + model="fake", + system="You are Tau.", + tools=[tool], + ) + ) + + _events = [event async for event in harness.prompt("read")] + + result = next(message for message in harness.messages if isinstance(message, ToolResultMessage)) + assert result.tool_call_id == "call-1" + assert result.content == "ok" + + @pytest.mark.anyio async def test_cancelled_tool_run_repairs_transcript_before_next_prompt() -> None: async def executor( diff --git a/tests/test_coding_session.py b/tests/test_coding_session.py index 21ed769ba..66de132bc 100644 --- a/tests/test_coding_session.py +++ b/tests/test_coding_session.py @@ -2781,6 +2781,80 @@ def create_provider( assert created == [("local", "qwen")] +@pytest.mark.anyio +async def test_session_resume_infers_provider_for_legacy_index_without_provider_name( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + manager = SessionManager(TauPaths(home=tmp_path / ".tau", agents_home=tmp_path / ".agents")) + first_record = manager.create_session( + cwd=tmp_path / "first", + model="gpt-5.5", + provider_name="openai-codex", + title="First", + ) + second_cwd = tmp_path / "second" + second_cwd.mkdir(parents=True) + second_record = manager.create_session( + cwd=second_cwd, + model="claude-opus-4.8", + provider_name=None, + title="Legacy Copilot Claude", + ) + assert second_record.provider_name is None + settings = ProviderSettings( + default_provider="openai-codex", + providers=( + OpenAICodexProviderConfig(models=("gpt-5.5",), default_model="gpt-5.5"), + OpenAICompatibleProviderConfig( + name="github-copilot", + base_url="https://api.githubcopilot.com", + api_key_env="GITHUB_COPILOT_TOKEN", + models=("claude-opus-4.8",), + default_model="claude-opus-4.8", + ), + ), + ) + created: list[tuple[str, str | None]] = [] + + def create_provider( + provider_config: object, + *, + credential_store: FileCredentialStore | None = None, + model: str | None = None, + thinking_level: str | None = None, + llm_observer: object | None = None, + ) -> SwitchableFakeProvider: + del credential_store, thinking_level, llm_observer + created.append((provider_config.name, model)) # type: ignore[attr-defined] + return SwitchableFakeProvider(provider_config) + + monkeypatch.setattr(coding_session_module, "create_model_provider", create_provider) + second_storage = JsonlSessionStorage(second_record.path) + await second_storage.append(SessionInfoEntry(cwd=str(second_record.cwd))) + await second_storage.append(ModelChangeEntry(model="claude-opus-4.8")) + session = await CodingSession.load( + CodingSessionConfig( + provider=FakeProvider([]), + model="gpt-5.5", + system="You are Tau.", + storage=JsonlSessionStorage(first_record.path), + cwd=first_record.cwd, + session_id=first_record.id, + session_manager=manager, + provider_name="openai-codex", + provider_settings=settings, + runtime_provider_config=settings.get_provider("openai-codex"), + ) + ) + created.clear() + + await session.resume(second_record.id) + + assert session.provider_name == "github-copilot" + assert session.model == "claude-opus-4.8" + assert created == [("github-copilot", "claude-opus-4.8")] + + @pytest.mark.anyio async def test_session_context_usage_recalculates_after_resume(tmp_path: Path) -> None: manager = SessionManager(TauPaths(home=tmp_path / ".tau", agents_home=tmp_path / ".agents")) From 25c0da225c27c70cd533f4e88940bb7d8271de4c Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Sat, 4 Jul 2026 23:58:41 +0100 Subject: [PATCH 09/10] Log concrete runtime provider selection --- src/tau_coding/diagnostics.py | 22 ++++++++++++++++++++++ src/tau_coding/session.py | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/tau_coding/diagnostics.py b/src/tau_coding/diagnostics.py index 8eec9ea35..3a9421ed9 100644 --- a/src/tau_coding/diagnostics.py +++ b/src/tau_coding/diagnostics.py @@ -53,6 +53,28 @@ def log_exception( self._append(entry) return self.path + def log_runtime_provider( + self, + *, + context: AgentCallDiagnosticContext, + phase: str, + provider: object, + ) -> Path: + """Log the concrete runtime provider object selected for a session phase.""" + entry = _base_entry(context, phase=phase, kind="runtime_provider") + provider_type = type(provider) + entry["runtime_provider"] = { + "class": provider_type.__qualname__, + "module": provider_type.__module__, + } + inner = getattr(provider, "_inner", None) + if inner is not None: + inner_type = type(inner) + entry["runtime_provider"]["inner_class"] = inner_type.__qualname__ + entry["runtime_provider"]["inner_module"] = inner_type.__module__ + self._append(entry) + return self.path + def log_error_event( self, *, diff --git a/src/tau_coding/session.py b/src/tau_coding/session.py index 1a2d5c71b..ddb6690de 100644 --- a/src/tau_coding/session.py +++ b/src/tau_coding/session.py @@ -726,6 +726,11 @@ def set_provider(self, provider_name: str, *, persist_default: bool = True) -> N self._owned_providers.append(provider) self._harness.config.provider = provider self._provider_name = provider_config.name + self._diagnostic_logger.log_runtime_provider( + context=self._diagnostic_context(), + phase="set_provider_runtime", + provider=provider, + ) self._runtime_provider_config = provider_config self._harness.config.model = model self._thinking_level = thinking_level @@ -827,6 +832,11 @@ def _refresh_runtime_provider(self) -> None: self._owned_providers.append(provider) self._harness.config.provider = provider self._runtime_provider_config = provider_config + self._diagnostic_logger.log_runtime_provider( + context=self._diagnostic_context(), + phase="refresh_runtime_provider", + provider=provider, + ) def reload(self) -> CodingReloadSummary: """Reload local coding resources and project context for future turns.""" From cf812dc862dade0f18aca919028da60596670733 Mon Sep 17 00:00:00 2001 From: Rui Carmo Date: Mon, 6 Jul 2026 07:54:09 +0100 Subject: [PATCH 10/10] Hide raw tool argument fallbacks --- src/tau_coding/branch_summary.py | 6 +++++- src/tau_coding/context_window.py | 8 +++++++- src/tau_coding/session_export.py | 7 ++++++- src/tau_coding/tui/state.py | 10 ++++++++-- tests/test_coding_session.py | 27 +++++++++++++++++++++++++++ tests/test_tui_adapter.py | 23 +++++++++++++++++++++++ 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/tau_coding/branch_summary.py b/src/tau_coding/branch_summary.py index adc1392ea..c345bb9af 100644 --- a/src/tau_coding/branch_summary.py +++ b/src/tau_coding/branch_summary.py @@ -158,8 +158,12 @@ def _format_assistant_summary_source(message: AssistantMessage) -> str: def _format_tool_call_arguments(arguments: Mapping[str, object]) -> str: + public_arguments = { + key: value for key, value in arguments.items() if key != "_raw_arguments" + } return ", ".join( - f"{key}={json.dumps(value, sort_keys=True)}" for key, value in sorted(arguments.items()) + f"{key}={json.dumps(value, sort_keys=True)}" + for key, value in sorted(public_arguments.items()) ) diff --git a/src/tau_coding/context_window.py b/src/tau_coding/context_window.py index 3d3d90f5c..37a2dc65e 100644 --- a/src/tau_coding/context_window.py +++ b/src/tau_coding/context_window.py @@ -238,7 +238,8 @@ def serialize_messages_for_compaction(messages: tuple[AgentMessage, ...]) -> str if message.tool_calls: lines.append("") for call in message.tool_calls: - lines.append(f"- {call.name}: {call.arguments}") + arguments = _public_tool_arguments(call.arguments) + lines.append(f"- {call.name}: {arguments}") lines.append("") lines.append("") case "tool": @@ -250,6 +251,11 @@ def serialize_messages_for_compaction(messages: tuple[AgentMessage, ...]) -> str return "\n".join(lines) +def _public_tool_arguments(arguments: dict[str, object]) -> dict[str, object]: + return {key: value for key, value in arguments.items() if key != "_raw_arguments"} + + + def _message_text(message: AgentMessage) -> str: match message.role: case "user": diff --git a/src/tau_coding/session_export.py b/src/tau_coding/session_export.py index 5881860ed..4b554f400 100644 --- a/src/tau_coding/session_export.py +++ b/src/tau_coding/session_export.py @@ -536,7 +536,7 @@ def _render_message_entry(entry: MessageEntry) -> str: "
  • " f"{_escape(call.name)} " f"{_escape(call.id)}" - f"
    {_escape(_json_dump(call.arguments))}
    " + f"
    {_escape(_json_dump(_public_tool_arguments(call.arguments)))}
    " "
  • " for call in message.tool_calls ) @@ -648,6 +648,11 @@ def _summarize_text(text: str, *, limit: int = 92) -> str: return summary[: limit - 3].rstrip() + "..." +def _public_tool_arguments(arguments: dict[str, JSONValue]) -> dict[str, JSONValue]: + return {key: value for key, value in arguments.items() if key != "_raw_arguments"} + + + def _json_dump(value: dict[str, JSONValue]) -> str: return json.dumps(value, indent=2, sort_keys=True) diff --git a/src/tau_coding/tui/state.py b/src/tau_coding/tui/state.py index 4d1a98981..92a732ba0 100644 --- a/src/tau_coding/tui/state.py +++ b/src/tau_coding/tui/state.py @@ -275,11 +275,17 @@ def _read_line_suffix(arguments: dict[str, JSONValue]) -> str: def _fallback_tool_call_invocation(tool_call: ToolCall) -> str: - if tool_call.arguments: - return f"{tool_call.name} {tool_call.arguments}" + display_arguments = _display_tool_arguments(tool_call.arguments) + if display_arguments: + return f"{tool_call.name} {display_arguments}" return tool_call.name +def _display_tool_arguments(arguments: dict[str, JSONValue]) -> dict[str, JSONValue]: + """Return human-facing tool arguments without parser-internal fallback data.""" + return {key: value for key, value in arguments.items() if key != "_raw_arguments"} + + def _string_argument(arguments: dict[str, JSONValue], key: str) -> str | None: value = arguments.get(key) return value if isinstance(value, str) else None diff --git a/tests/test_coding_session.py b/tests/test_coding_session.py index 66de132bc..6525f9326 100644 --- a/tests/test_coding_session.py +++ b/tests/test_coding_session.py @@ -2904,3 +2904,30 @@ def test_minimal_commands_are_handled(tmp_path: Path) -> None: assert session.handle_command("/quit").exit_requested is True assert session.handle_command("/exit").exit_requested is True assert session.handle_command("/unknown").message == "Unknown command: /unknown" + + +def test_branch_summary_hides_raw_argument_fallbacks() -> None: + from tau_coding.branch_summary import _format_assistant_summary_source + + summary = _format_assistant_summary_source( + AssistantMessage( + content="", + tool_calls=[ + ToolCall( + id="call-raw", + name="read", + arguments={"_raw_arguments": "{not valid json"}, + ), + ToolCall( + id="call-partial", + name="custom", + arguments={"path": "README.md", "_raw_arguments": "{not valid json"}, + ), + ], + ) + ) + + assert "_raw_arguments" not in summary + assert "{not valid json" not in summary + assert "read()" in summary + assert 'custom(path="README.md")' in summary diff --git a/tests/test_tui_adapter.py b/tests/test_tui_adapter.py index d81179656..c25e72a1b 100644 --- a/tests/test_tui_adapter.py +++ b/tests/test_tui_adapter.py @@ -204,6 +204,29 @@ def test_tool_call_blocks_use_human_readable_invocations() -> None: ) +def test_tool_call_blocks_hide_raw_argument_fallbacks() -> None: + assert ( + format_tool_call_block( + ToolCall( + id="call-raw", + name="read", + arguments={"_raw_arguments": "{not valid json"}, + ) + ) + == "→ read" + ) + assert ( + format_tool_call_block( + ToolCall( + id="call-partial", + name="custom", + arguments={"path": "README.md", "_raw_arguments": "{not valid json"}, + ) + ) + == "→ custom {'path': 'README.md'}" + ) + + def test_tui_adapter_records_tool_updates_and_results() -> None: state = TuiState() adapter = TuiEventAdapter(state)