v2.7.5 (#589)
Co-authored-by: Bastien <bastien.baret@gmail.com> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
e9a9217cc8
commit
e1a25caa52
85 changed files with 2830 additions and 594 deletions
|
|
@ -26,6 +26,15 @@ def run_onboarding(app: App | None = None) -> None:
|
|||
case None:
|
||||
rprint("\n[yellow]Setup cancelled. See you next time![/]")
|
||||
sys.exit(0)
|
||||
case str() as s if s.startswith("env_var_error:"):
|
||||
env_key = s.removeprefix("env_var_error:")
|
||||
rprint(
|
||||
"\n[yellow]Could not save the API key because this provider is "
|
||||
f"configured with an invalid environment variable name: {env_key}.[/]"
|
||||
"\n[dim]The API key was not saved for this session. "
|
||||
"Update the provider's `api_key_env_var` setting in your config and try again.[/]\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
case str() as s if s.startswith("save_error:"):
|
||||
err = s.removeprefix("save_error:")
|
||||
rprint(
|
||||
|
|
|
|||
208
vibe/setup/onboarding/context.py
Normal file
208
vibe/setup/onboarding/context.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import tomllib
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
||||
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.config._settings import (
|
||||
DEFAULT_ACTIVE_MODEL,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_PROVIDERS,
|
||||
)
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.logger import logger
|
||||
|
||||
_ONBOARDING_LIST_ADAPTER = TypeAdapter(list[Any])
|
||||
|
||||
|
||||
def _default_provider_payloads() -> list[dict[str, Any]]:
|
||||
return [provider.model_dump(mode="json") for provider in DEFAULT_PROVIDERS]
|
||||
|
||||
|
||||
def _default_model_payloads() -> list[dict[str, Any]]:
|
||||
return [model.model_dump(mode="json") for model in DEFAULT_MODELS]
|
||||
|
||||
|
||||
class _OnboardingSnapshot(BaseModel):
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||
providers: list[Any] = Field(default_factory=_default_provider_payloads)
|
||||
models: list[Any] = Field(default_factory=_default_model_payloads)
|
||||
|
||||
|
||||
_ONBOARDING_FIELDS = frozenset(_OnboardingSnapshot.model_fields)
|
||||
|
||||
|
||||
def _can_resolve_provider_from_explicit_overrides(
|
||||
explicit_overrides: dict[str, Any],
|
||||
) -> bool:
|
||||
return "providers" in explicit_overrides
|
||||
|
||||
|
||||
def _find_env_value(name: str) -> str | None:
|
||||
expected_name = name.upper()
|
||||
for env_name, value in os.environ.items():
|
||||
if env_name.upper() == expected_name:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _load_onboarding_toml_payload() -> dict[str, Any]:
|
||||
try:
|
||||
harness_files = get_harness_files_manager()
|
||||
except RuntimeError:
|
||||
return {}
|
||||
|
||||
config_file = harness_files.config_file
|
||||
if config_file is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with config_file.open("rb") as file:
|
||||
toml_data = tomllib.load(file)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except tomllib.TOMLDecodeError as err:
|
||||
raise RuntimeError(f"Invalid TOML in {config_file}: {err}") from err
|
||||
except OSError as err:
|
||||
raise RuntimeError(f"Cannot read {config_file}: {err}") from err
|
||||
|
||||
return {
|
||||
field_name: toml_data[field_name]
|
||||
for field_name in _ONBOARDING_FIELDS
|
||||
if field_name in toml_data
|
||||
}
|
||||
|
||||
|
||||
def _load_onboarding_env_payload_for_fields(
|
||||
field_names: frozenset[str],
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
if (
|
||||
"active_model" in field_names
|
||||
and (active_model := _find_env_value("VIBE_ACTIVE_MODEL")) is not None
|
||||
):
|
||||
payload["active_model"] = active_model
|
||||
if (
|
||||
"providers" in field_names
|
||||
and (providers := _find_env_value("VIBE_PROVIDERS")) is not None
|
||||
):
|
||||
payload["providers"] = _ONBOARDING_LIST_ADAPTER.validate_json(providers)
|
||||
if (
|
||||
"models" in field_names
|
||||
and (models := _find_env_value("VIBE_MODELS")) is not None
|
||||
):
|
||||
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _explicit_onboarding_overrides(**overrides: Any) -> dict[str, Any]:
|
||||
return {
|
||||
field_name: value
|
||||
for field_name, value in overrides.items()
|
||||
if field_name in _ONBOARDING_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def _build_onboarding_snapshot_payload(**overrides: Any) -> dict[str, Any]:
|
||||
explicit_overrides = _explicit_onboarding_overrides(**overrides)
|
||||
payload = _OnboardingSnapshot().model_dump()
|
||||
|
||||
if explicit_overrides.keys() >= _ONBOARDING_FIELDS:
|
||||
payload.update(explicit_overrides)
|
||||
return payload
|
||||
|
||||
try:
|
||||
payload.update(_load_onboarding_toml_payload())
|
||||
except RuntimeError:
|
||||
if not _can_resolve_provider_from_explicit_overrides(explicit_overrides):
|
||||
raise
|
||||
try:
|
||||
payload.update(
|
||||
_load_onboarding_env_payload_for_fields(
|
||||
_ONBOARDING_FIELDS.difference(explicit_overrides)
|
||||
)
|
||||
)
|
||||
except (ValidationError, ValueError):
|
||||
if not _can_resolve_provider_from_explicit_overrides(explicit_overrides):
|
||||
raise
|
||||
payload.update(explicit_overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _validated_payloads[PayloadConfig: ModelConfig | ProviderConfig](
|
||||
payloads: list[Any], model_type: type[PayloadConfig]
|
||||
) -> list[PayloadConfig]:
|
||||
validated_payloads: list[PayloadConfig] = []
|
||||
for payload in payloads:
|
||||
if isinstance(payload, model_type):
|
||||
validated_payloads.append(payload)
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
try:
|
||||
validated_payloads.append(model_type.model_validate(payload))
|
||||
except (ValidationError, ValueError):
|
||||
continue
|
||||
return validated_payloads
|
||||
|
||||
|
||||
def _resolve_provider(
|
||||
*, active_model: str, snapshot: _OnboardingSnapshot
|
||||
) -> ProviderConfig:
|
||||
providers_by_name: dict[str, ProviderConfig] = {}
|
||||
for provider in _validated_payloads(snapshot.providers, ProviderConfig):
|
||||
providers_by_name.setdefault(provider.name, provider)
|
||||
|
||||
models = _validated_payloads(snapshot.models, ModelConfig)
|
||||
|
||||
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL):
|
||||
for model in models:
|
||||
if model.alias != model_alias:
|
||||
continue
|
||||
if provider := providers_by_name.get(model.provider):
|
||||
return provider
|
||||
|
||||
for model in models:
|
||||
if provider := providers_by_name.get(model.provider):
|
||||
return provider
|
||||
|
||||
if len(providers_by_name) == 1:
|
||||
return next(iter(providers_by_name.values()))
|
||||
|
||||
return DEFAULT_PROVIDERS[0]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingContext:
|
||||
provider: ProviderConfig
|
||||
|
||||
@property
|
||||
def supports_browser_sign_in(self) -> bool:
|
||||
return self.provider.supports_browser_sign_in
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: VibeConfig) -> OnboardingContext:
|
||||
return cls(provider=config.get_provider_for_model(config.get_active_model()))
|
||||
|
||||
@classmethod
|
||||
def load(cls, **overrides: Any) -> OnboardingContext:
|
||||
try:
|
||||
snapshot = _OnboardingSnapshot.model_validate(
|
||||
_build_onboarding_snapshot_payload(**overrides)
|
||||
)
|
||||
return cls(
|
||||
provider=_resolve_provider(
|
||||
active_model=snapshot.active_model, snapshot=snapshot
|
||||
)
|
||||
)
|
||||
except (RuntimeError, ValidationError, ValueError):
|
||||
logger.warning(
|
||||
"Onboarding config fallback activated; using defaults", exc_info=True
|
||||
)
|
||||
return cls.from_config(VibeConfig.model_construct())
|
||||
|
|
@ -13,11 +13,12 @@ from textual.widgets import Input, Link, Static
|
|||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
PROVIDER_HELP = {
|
||||
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
|
||||
|
|
@ -32,6 +33,42 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
|||
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||
|
||||
|
||||
def persist_api_key(provider: ProviderConfig, api_key: str) -> str:
|
||||
env_key = provider.api_key_env_var
|
||||
if not env_key:
|
||||
return "env_var_error:<empty>"
|
||||
try:
|
||||
os.environ[env_key] = api_key
|
||||
except ValueError:
|
||||
return f"env_var_error:{env_key}"
|
||||
try:
|
||||
_save_api_key_to_env_file(env_key, api_key)
|
||||
except (OSError, ValueError) as err:
|
||||
return f"save_error:{err}"
|
||||
if provider.backend == Backend.MISTRAL:
|
||||
try:
|
||||
telemetry = TelemetryClient(config_getter=VibeConfig)
|
||||
telemetry.send_onboarding_api_key_added()
|
||||
except Exception:
|
||||
pass
|
||||
return "completed"
|
||||
|
||||
|
||||
def _get_mistral_provider() -> ProviderConfig:
|
||||
return next(
|
||||
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_onboarding_provider(
|
||||
provider: ProviderConfig | None = None,
|
||||
) -> ProviderConfig:
|
||||
resolved_provider = provider or OnboardingContext.load().provider
|
||||
if resolved_provider.api_key_env_var:
|
||||
return resolved_provider
|
||||
return _get_mistral_provider()
|
||||
|
||||
|
||||
class ApiKeyScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
|
|
@ -40,11 +77,9 @@ class ApiKeyScreen(OnboardingScreen):
|
|||
|
||||
NEXT_SCREEN = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, provider: ProviderConfig | None = None) -> None:
|
||||
super().__init__()
|
||||
config = VibeConfig.model_construct()
|
||||
active_model = config.get_active_model()
|
||||
self.provider = config.get_provider_for_model(active_model)
|
||||
self.provider = _resolve_onboarding_provider(provider)
|
||||
|
||||
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
|
||||
if self.provider.name not in PROVIDER_HELP:
|
||||
|
|
@ -124,20 +159,7 @@ class ApiKeyScreen(OnboardingScreen):
|
|||
self._save_and_finish(event.value)
|
||||
|
||||
def _save_and_finish(self, api_key: str) -> None:
|
||||
env_key = self.provider.api_key_env_var
|
||||
os.environ[env_key] = api_key
|
||||
try:
|
||||
_save_api_key_to_env_file(env_key, api_key)
|
||||
except OSError as err:
|
||||
self.app.exit(f"save_error:{err}")
|
||||
return
|
||||
if self.provider.backend == Backend.MISTRAL:
|
||||
try:
|
||||
telemetry = TelemetryClient(config_getter=VibeConfig)
|
||||
telemetry.send_onboarding_api_key_added()
|
||||
except Exception:
|
||||
pass # Telemetry is fire-and-forget; don't fail onboarding
|
||||
self.app.exit("completed")
|
||||
self.app.exit(persist_api_key(self.provider, api_key))
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
copy_selection_to_clipboard(self.app)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any, ClassVar
|
|||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import CenterMiddle, Horizontal
|
||||
from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
|
|
@ -38,46 +38,77 @@ class TrustFolderDialog(CenterMiddle):
|
|||
class Untrusted(Message):
|
||||
pass
|
||||
|
||||
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self, folder_path: Path, detected_files: list[str], **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.folder_path = folder_path
|
||||
self.detected_files = detected_files
|
||||
self.selected_option = 0
|
||||
self.option_widgets: list[Static] = []
|
||||
|
||||
def _compose_scroll_content(self) -> ComposeResult:
|
||||
why_content = (
|
||||
"Files here can modify AI behavior. Malicious "
|
||||
"configs may exfiltrate data, run destructive "
|
||||
"commands, or silently alter your code."
|
||||
)
|
||||
with Center(classes="trust-dialog-section-center"):
|
||||
yield NoMarkupStatic(
|
||||
why_content,
|
||||
id="trust-dialog-warning",
|
||||
classes="trust-dialog-section-content",
|
||||
)
|
||||
|
||||
if self.detected_files:
|
||||
yield NoMarkupStatic(
|
||||
"Detected configuration files\n", classes="trust-dialog-section-header"
|
||||
)
|
||||
file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files)
|
||||
with Center(classes="trust-dialog-section-center"):
|
||||
yield NoMarkupStatic(
|
||||
file_list,
|
||||
id="trust-dialog-files",
|
||||
classes="trust-dialog-section-content",
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with CenterMiddle(id="trust-dialog"):
|
||||
yield NoMarkupStatic(
|
||||
"⚠ Trust this folder and all its subfolders?", id="trust-dialog-title"
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
str(self.folder_path),
|
||||
id="trust-dialog-path",
|
||||
classes="trust-dialog-path",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
|
||||
id="trust-dialog-message",
|
||||
classes="trust-dialog-message",
|
||||
)
|
||||
with CenterMiddle(id="trust-dialog-container"):
|
||||
with CenterMiddle(id="trust-dialog"):
|
||||
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
|
||||
yield NoMarkupStatic(
|
||||
str(self.folder_path),
|
||||
id="trust-dialog-path",
|
||||
classes="trust-dialog-path",
|
||||
)
|
||||
|
||||
with Horizontal(id="trust-options-container"):
|
||||
options = ["Yes", "No"]
|
||||
for idx, text in enumerate(options):
|
||||
widget = NoMarkupStatic(
|
||||
f" {idx + 1}. {text}", classes="trust-option"
|
||||
)
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
with VerticalScroll(id="trust-dialog-content"):
|
||||
yield from self._compose_scroll_content()
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"← → navigate Enter select", classes="trust-dialog-help"
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"Only trust folders you fully control",
|
||||
id="trust-dialog-footer-warning",
|
||||
classes="trust-dialog-footer-warning",
|
||||
)
|
||||
|
||||
yield NoMarkupStatic(
|
||||
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
|
||||
id="trust-dialog-save-info",
|
||||
classes="trust-dialog-save-info",
|
||||
)
|
||||
with Horizontal(id="trust-options-container"):
|
||||
options = ["Yes, trust this folder", "No, ignore config files"]
|
||||
for idx, text in enumerate(options):
|
||||
widget = NoMarkupStatic(
|
||||
f" {idx + 1}. {text}", classes="trust-option"
|
||||
)
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"← → navigate Enter select", classes="trust-dialog-help"
|
||||
)
|
||||
|
||||
yield NoMarkupStatic(
|
||||
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
|
||||
id="trust-dialog-save-info",
|
||||
classes="trust-dialog-save-info",
|
||||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.selected_option = 1 # Default to "No"
|
||||
|
|
@ -85,7 +116,7 @@ class TrustFolderDialog(CenterMiddle):
|
|||
self.focus()
|
||||
|
||||
def _update_options(self) -> None:
|
||||
options = ["Yes", "No"]
|
||||
options = ["Yes, trust this folder", "No, ignore config files"]
|
||||
|
||||
if len(self.option_widgets) != len(options):
|
||||
return
|
||||
|
|
@ -146,9 +177,12 @@ class TrustFolderApp(App):
|
|||
Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self, folder_path: Path, detected_files: list[str], **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.folder_path = folder_path
|
||||
self.detected_files = detected_files
|
||||
self._result: bool | None = None
|
||||
self._quit_without_saving = False
|
||||
|
||||
|
|
@ -156,7 +190,7 @@ class TrustFolderApp(App):
|
|||
self.theme = "textual-ansi"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TrustFolderDialog(self.folder_path)
|
||||
yield TrustFolderDialog(self.folder_path, self.detected_files)
|
||||
|
||||
def action_quit_without_saving(self) -> None:
|
||||
self._quit_without_saving = True
|
||||
|
|
@ -177,6 +211,6 @@ class TrustFolderApp(App):
|
|||
return self._result
|
||||
|
||||
|
||||
def ask_trust_folder(folder_path: Path) -> bool | None:
|
||||
app = TrustFolderApp(folder_path)
|
||||
def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
|
||||
app = TrustFolderApp(folder_path, detected_files)
|
||||
return app.run_trust_dialog()
|
||||
|
|
|
|||
|
|
@ -3,14 +3,25 @@ Screen {
|
|||
background: transparent 80%;
|
||||
}
|
||||
|
||||
|
||||
#trust-dialog {
|
||||
width: 70;
|
||||
max-width: 90%;
|
||||
min-width: 50;
|
||||
height: auto;
|
||||
max-width: 70;
|
||||
overflow-y: scroll;
|
||||
border: round ansi_bright_black;
|
||||
background: transparent;
|
||||
height:auto;
|
||||
max-height: 1fr;
|
||||
padding: 1;
|
||||
padding-left:5;
|
||||
padding-right:5;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
#trust-dialog-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
max-height: 10;
|
||||
}
|
||||
|
||||
#trust-dialog-title {
|
||||
|
|
@ -31,15 +42,42 @@ Screen {
|
|||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#trust-dialog-message {
|
||||
.trust-dialog-section-header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
.trust-dialog-section-center {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.trust-dialog-section-content {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
text-align: center;
|
||||
text-overflow: fold;
|
||||
}
|
||||
|
||||
.trust-dialog-footer-warning {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_yellow;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
border-top: solid ansi_bright_black;
|
||||
border-bottom: solid ansi_bright_black;
|
||||
margin-bottom: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#trust-options-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
@ -79,5 +117,4 @@ Screen {
|
|||
text-align: center;
|
||||
text-style: italic;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue