From 25b269bb42434c6c00bef50d262dfa05e146ba36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:30:27 +0000 Subject: [PATCH 01/10] Initial plan From 80e7c254637fcb319c86642efddd43067573e8b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:37:14 +0000 Subject: [PATCH 02/10] feat(accounts): add per-user LLM provider configuration with encrypted API keys - Add cryptography dependency for Fernet encryption - Create encryption utility (encrypt_value/decrypt_value) - Add UserLLMConfig model with encrypted API key storage - Add serializers, views, and URL routes for CRUD operations - Update LLM config to support per-user provider configuration - Add comprehensive tests for encryption, model, and API endpoints Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- backend/apps/accounts/encryption.py | 65 +++++++ .../migrations/0003_add_userllmconfig.py | 61 +++++++ backend/apps/accounts/models.py | 66 +++++++ backend/apps/accounts/serializers.py | 67 ++++++- backend/apps/accounts/tests.py | 166 ++++++++++++++++++ backend/apps/accounts/urls.py | 6 + backend/apps/accounts/views.py | 38 +++- backend/apps/design_system/llm/config.py | 43 +++-- backend/pyproject.toml | 1 + backend/uv.lock | 95 +++++----- 10 files changed, 546 insertions(+), 62 deletions(-) create mode 100644 backend/apps/accounts/encryption.py create mode 100644 backend/apps/accounts/migrations/0003_add_userllmconfig.py diff --git a/backend/apps/accounts/encryption.py b/backend/apps/accounts/encryption.py new file mode 100644 index 0000000..bde222b --- /dev/null +++ b/backend/apps/accounts/encryption.py @@ -0,0 +1,65 @@ +""" +Encryption utilities for sensitive data storage. + +Uses Fernet symmetric encryption with a key derived from Django's SECRET_KEY. +This ensures that encrypted data in the database cannot be read without +the application's secret key. +""" +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken +from django.conf import settings + + +def _get_fernet_key() -> bytes: + """ + Derive a Fernet-compatible key from Django's SECRET_KEY. + + Uses SHA-256 to produce a 32-byte digest, then base64url-encodes it + to create a valid Fernet key (44 URL-safe base64-encoded bytes). + """ + secret = settings.SECRET_KEY.encode() + digest = hashlib.sha256(secret).digest() + return base64.urlsafe_b64encode(digest) + + +def _get_fernet() -> Fernet: + """Return a Fernet instance using the derived key.""" + return Fernet(_get_fernet_key()) + + +def encrypt_value(plaintext: str) -> str: + """ + Encrypt a plaintext string and return a URL-safe base64-encoded token. + + Args: + plaintext: The string to encrypt. + + Returns: + The encrypted value as a URL-safe base64 string. + """ + if not plaintext: + return "" + f = _get_fernet() + return f.encrypt(plaintext.encode()).decode() + + +def decrypt_value(token: str) -> str: + """ + Decrypt a Fernet token back to the original plaintext string. + + Args: + token: The encrypted value (URL-safe base64 string). + + Returns: + The decrypted plaintext string. + + Raises: + InvalidToken: If the token is invalid or was encrypted with + a different key. + """ + if not token: + return "" + f = _get_fernet() + return f.decrypt(token.encode()).decode() diff --git a/backend/apps/accounts/migrations/0003_add_userllmconfig.py b/backend/apps/accounts/migrations/0003_add_userllmconfig.py new file mode 100644 index 0000000..32cab09 --- /dev/null +++ b/backend/apps/accounts/migrations/0003_add_userllmconfig.py @@ -0,0 +1,61 @@ +# Generated by Django 6.0.1 on 2026-02-26 05:34 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0002_userapikey"), + ] + + operations = [ + migrations.CreateModel( + name="UserLLMConfig", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created at")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated at")), + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ( + "provider", + models.CharField( + choices=[("gemini", "Gemini"), ("openrouter", "OpenRouter")], + help_text="LLM provider name", + max_length=20, + verbose_name="Provider", + ), + ), + ( + "api_key_encrypted", + models.TextField(help_text="Fernet-encrypted API key", verbose_name="Encrypted API key"), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this provider configuration is active", + verbose_name="Is Active", + ), + ), + ( + "user", + models.ForeignKey( + help_text="User who owns this LLM configuration", + on_delete=django.db.models.deletion.CASCADE, + related_name="llm_configs", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "User LLM configuration", + "verbose_name_plural": "User LLM configurations", + "ordering": ["-created_at"], + "constraints": [models.UniqueConstraint(fields=("user", "provider"), name="unique_user_provider")], + }, + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 907d2b4..864da6a 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -199,3 +199,69 @@ def update_last_used(self): self.last_used_at = timezone.now() self.save(update_fields=['last_used_at']) + + +class UserLLMConfig(TimeStampedModel): + """ + Per-user LLM provider configuration. + + Each user can configure one entry per provider with their own API key. + API keys are encrypted at rest using Fernet symmetric encryption. + """ + + PROVIDER_CHOICES = [ + ('gemini', _('Gemini')), + ('openrouter', _('OpenRouter')), + ] + + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False + ) + user = models.ForeignKey( + 'accounts.User', + on_delete=models.CASCADE, + related_name='llm_configs', + verbose_name=_('User'), + help_text=_('User who owns this LLM configuration') + ) + provider = models.CharField( + max_length=20, + choices=PROVIDER_CHOICES, + verbose_name=_('Provider'), + help_text=_('LLM provider name') + ) + api_key_encrypted = models.TextField( + verbose_name=_('Encrypted API key'), + help_text=_('Fernet-encrypted API key') + ) + is_active = models.BooleanField( + default=True, + verbose_name=_('Is Active'), + help_text=_('Whether this provider configuration is active') + ) + + class Meta: + verbose_name = _('User LLM configuration') + verbose_name_plural = _('User LLM configurations') + ordering = ['-created_at'] + constraints = [ + models.UniqueConstraint( + fields=['user', 'provider'], + name='unique_user_provider' + ) + ] + + def __str__(self): + return f'{self.user.email} - {self.get_provider_display()}' + + def set_api_key(self, plaintext_key: str): + """Encrypt and store the API key.""" + from .encryption import encrypt_value + self.api_key_encrypted = encrypt_value(plaintext_key) + + def get_api_key(self) -> str: + """Decrypt and return the API key.""" + from .encryption import decrypt_value + return decrypt_value(self.api_key_encrypted) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 35cff3a..a3cbcc2 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -4,7 +4,7 @@ from rest_framework import serializers from rest_framework.validators import UniqueValidator -from .models import UserAPIKey +from .models import UserAPIKey, UserLLMConfig User = get_user_model() @@ -124,3 +124,68 @@ class Meta: model = UserAPIKey fields = ['id', 'name', 'key', 'key_prefix', 'expires_at', 'created_at'] read_only_fields = ['id', 'key', 'key_prefix', 'created_at'] + + +class UserLLMConfigSerializer(serializers.ModelSerializer): + """ + Serializer for UserLLMConfig. + Accepts a plaintext ``api_key`` on write and returns a masked + version on read. The actual encrypted blob is never exposed. + """ + + api_key = serializers.CharField( + write_only=True, + required=True, + help_text=_('Plaintext API key (write-only)') + ) + api_key_display = serializers.SerializerMethodField() + provider_display = serializers.CharField( + source='get_provider_display', + read_only=True + ) + + class Meta: + model = UserLLMConfig + fields = [ + 'id', 'provider', 'provider_display', + 'api_key', 'api_key_display', + 'is_active', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_api_key_display(self, obj): + """Return a masked version of the API key.""" + try: + key = obj.get_api_key() + if len(key) > 8: + return f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}" + return '****' + except Exception: + return '****' + + def validate(self, attrs): + # On create, check uniqueness of (user, provider) + if self.instance is None: + user = self.context['request'].user + provider = attrs.get('provider') + if UserLLMConfig.objects.filter(user=user, provider=provider).exists(): + raise serializers.ValidationError({ + 'provider': _('A configuration for this provider already exists.') + }) + return attrs + + def create(self, validated_data): + api_key = validated_data.pop('api_key') + instance = UserLLMConfig(**validated_data) + instance.set_api_key(api_key) + instance.save() + return instance + + def update(self, instance, validated_data): + api_key = validated_data.pop('api_key', None) + if api_key: + instance.set_api_key(api_key) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance diff --git a/backend/apps/accounts/tests.py b/backend/apps/accounts/tests.py index 75a1c16..a6b9391 100644 --- a/backend/apps/accounts/tests.py +++ b/backend/apps/accounts/tests.py @@ -614,5 +614,171 @@ def test_unauthenticated_api_keys_returns_401(self): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) +# --------------------------------------------------------------------------- +# Encryption Utility Tests +# --------------------------------------------------------------------------- + +class TestEncryptionUtils(unittest.TestCase): + """Tests for the Fernet encryption utilities.""" + + @override_settings(SECRET_KEY="test-secret-key-for-encryption") + def test_encrypt_decrypt_roundtrip(self): + from apps.accounts.encryption import encrypt_value, decrypt_value + original = "sk_my-super-secret-api-key-12345" + encrypted = encrypt_value(original) + self.assertNotEqual(encrypted, original) + self.assertEqual(decrypt_value(encrypted), original) + + @override_settings(SECRET_KEY="test-secret-key-for-encryption") + def test_encrypt_empty_string(self): + from apps.accounts.encryption import encrypt_value, decrypt_value + self.assertEqual(encrypt_value(""), "") + self.assertEqual(decrypt_value(""), "") + + @override_settings(SECRET_KEY="test-secret-key-for-encryption") + def test_different_secret_key_fails(self): + from apps.accounts.encryption import encrypt_value + encrypted = encrypt_value("secret") + # Decrypting with a different key should raise + from cryptography.fernet import InvalidToken + with override_settings(SECRET_KEY="different-secret-key"): + from apps.accounts.encryption import decrypt_value + with self.assertRaises(InvalidToken): + decrypt_value(encrypted) + + +# --------------------------------------------------------------------------- +# UserLLMConfig Model Tests +# --------------------------------------------------------------------------- + +class TestUserLLMConfigModel(TestCase): + """Tests for the UserLLMConfig model.""" + + def setUp(self): + self.user = User.objects.create_user( + email="llm@test.com", password="StrongP@ss123!" + ) + + def test_set_and_get_api_key(self): + """API key should survive encrypt → store → decrypt round-trip.""" + from apps.accounts.models import UserLLMConfig + config = UserLLMConfig(user=self.user, provider="gemini") + config.set_api_key("AIzaSy-test-key-123") + config.save() + config.refresh_from_db() + self.assertEqual(config.get_api_key(), "AIzaSy-test-key-123") + + def test_encrypted_value_differs_from_plaintext(self): + """The stored encrypted value must differ from the original.""" + from apps.accounts.models import UserLLMConfig + config = UserLLMConfig(user=self.user, provider="openrouter") + config.set_api_key("sk_live_abc") + self.assertNotEqual(config.api_key_encrypted, "sk_live_abc") + + def test_unique_user_provider_constraint(self): + """Each user can only have one config per provider.""" + from django.db import IntegrityError + from apps.accounts.models import UserLLMConfig + UserLLMConfig.objects.create( + user=self.user, provider="gemini", api_key_encrypted="x" + ) + with self.assertRaises(IntegrityError): + UserLLMConfig.objects.create( + user=self.user, provider="gemini", api_key_encrypted="y" + ) + + def test_str_representation(self): + from apps.accounts.models import UserLLMConfig + config = UserLLMConfig(user=self.user, provider="gemini") + self.assertIn("llm@test.com", str(config)) + + +# --------------------------------------------------------------------------- +# UserLLMConfig API View Tests +# --------------------------------------------------------------------------- + +class TestLLMConfigViews(AccountsAPITestBase): + """Tests for the LLM configuration CRUD endpoints.""" + + LLM_CONFIGS_URL = "/api/accounts/llm-configs/" + + def setUp(self): + super().setUp() + self.user = self._create_user() + self._authenticate(self.user) + + def test_create_llm_config(self): + """POST should create a new LLM provider configuration.""" + data = {"provider": "gemini", "api_key": "AIzaSy-test-key"} + response = self.client.post(self.LLM_CONFIGS_URL, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["provider"], "gemini") + # api_key should not be in response (write-only) + self.assertNotIn("api_key", response.data) + # masked display should be present + self.assertIn("api_key_display", response.data) + + def test_list_llm_configs(self): + """GET should list the authenticated user's configurations.""" + from apps.accounts.models import UserLLMConfig + cfg = UserLLMConfig(user=self.user, provider="openrouter") + cfg.set_api_key("sk-or-test") + cfg.save() + response = self.client.get(self.LLM_CONFIGS_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_llm_config(self): + """PATCH should update the API key.""" + from apps.accounts.models import UserLLMConfig + cfg = UserLLMConfig(user=self.user, provider="gemini") + cfg.set_api_key("old-key") + cfg.save() + url = f"{self.LLM_CONFIGS_URL}{cfg.pk}/" + response = self.client.patch(url, {"api_key": "new-key"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + cfg.refresh_from_db() + self.assertEqual(cfg.get_api_key(), "new-key") + + def test_delete_llm_config(self): + """DELETE should remove the configuration.""" + from apps.accounts.models import UserLLMConfig + cfg = UserLLMConfig(user=self.user, provider="gemini") + cfg.set_api_key("to-delete") + cfg.save() + url = f"{self.LLM_CONFIGS_URL}{cfg.pk}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(UserLLMConfig.objects.filter(pk=cfg.pk).exists()) + + def test_duplicate_provider_returns_400(self): + """Creating a second config for the same provider should fail.""" + data = {"provider": "gemini", "api_key": "key-1"} + self.client.post(self.LLM_CONFIGS_URL, data, format="json") + response = self.client.post( + self.LLM_CONFIGS_URL, + {"provider": "gemini", "api_key": "key-2"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_cannot_see_other_users_config(self): + """Users should only see their own configurations.""" + from apps.accounts.models import UserLLMConfig + other = self._create_user(email="other@test.com") + cfg = UserLLMConfig(user=other, provider="gemini") + cfg.set_api_key("other-key") + cfg.save() + response = self.client.get(self.LLM_CONFIGS_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + def test_unauthenticated_returns_401(self): + """LLM config endpoints should require authentication.""" + self.client.credentials() + response = self.client.get(self.LLM_CONFIGS_URL) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + if __name__ == "__main__": unittest.main() diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index b1b03ed..b27b6b1 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -9,6 +9,8 @@ ChangePasswordView, UserAPIKeyListCreateView, UserAPIKeyDetailView, + UserLLMConfigListCreateView, + UserLLMConfigDetailView, ) app_name = 'accounts' @@ -24,4 +26,8 @@ # API Keys path('api-keys/', UserAPIKeyListCreateView.as_view(), name='api-key-list-create'), path('api-keys//', UserAPIKeyDetailView.as_view(), name='api-key-detail'), + + # LLM Configurations + path('llm-configs/', UserLLMConfigListCreateView.as_view(), name='llm-config-list-create'), + path('llm-configs//', UserLLMConfigDetailView.as_view(), name='llm-config-detail'), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 8621086..395b6b4 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -14,8 +14,9 @@ ChangePasswordSerializer, UserAPIKeySerializer, CreateUserAPIKeySerializer, + UserLLMConfigSerializer, ) -from .models import UserAPIKey +from .models import UserAPIKey, UserLLMConfig User = get_user_model() @@ -193,3 +194,38 @@ def destroy(self, request, *args, **kwargs): return Response({ 'message': _('API key deleted successfully') }, status=status.HTTP_200_OK) + + +class UserLLMConfigListCreateView(generics.ListCreateAPIView): + """ + API endpoint to list and create LLM provider configurations + for the authenticated user. + """ + permission_classes = [IsAuthenticated] + serializer_class = UserLLMConfigSerializer + pagination_class = None + + def get_queryset(self): + return UserLLMConfig.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class UserLLMConfigDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint to retrieve, update, or delete a specific LLM + provider configuration. + """ + permission_classes = [IsAuthenticated] + serializer_class = UserLLMConfigSerializer + + def get_queryset(self): + return UserLLMConfig.objects.filter(user=self.request.user) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response({ + 'message': _('LLM configuration deleted successfully') + }, status=status.HTTP_200_OK) diff --git a/backend/apps/design_system/llm/config.py b/backend/apps/design_system/llm/config.py index fcbc523..fdfb2a6 100644 --- a/backend/apps/design_system/llm/config.py +++ b/backend/apps/design_system/llm/config.py @@ -29,34 +29,52 @@ def get_provider_config( provider_type: LLMProviderType, - for_vision: bool = False + for_vision: bool = False, + user=None ) -> Optional[LLMConfig]: """ Get LLM provider configuration from Django settings or environment variables. Configuration priority: - 1. Django settings (LLM_PROVIDERS dict) - 2. Environment variables (e.g., OPENAI_API_KEY, GEMINI_API_KEY) + 1. Per-user configuration (UserLLMConfig) when a user is provided + 2. Django settings (LLM_PROVIDERS dict) + 3. Environment variables (e.g., OPENAI_API_KEY, GEMINI_API_KEY) Args: provider_type: The type of LLM provider for_vision: If True, use vision-capable model + user: Optional user instance to check per-user config first Returns: LLMConfig if configuration is available, None otherwise """ - # Try Django settings first + api_key = None + + # 1. Try per-user configuration + if user is not None: + try: + from apps.accounts.models import UserLLMConfig + user_config = UserLLMConfig.objects.filter( + user=user, + provider=provider_type.value, + is_active=True + ).first() + if user_config: + api_key = user_config.get_api_key() + except Exception: + pass + + # 2. Try Django settings / env vars as fallback llm_settings = getattr(settings, 'LLM_PROVIDERS', {}) provider_settings = llm_settings.get(provider_type.value, {}) - - # Environment variable names for each provider + env_key_mapping = { LLMProviderType.GEMINI: 'GEMINI_API_KEY', LLMProviderType.OPENROUTER: 'OPENROUTER_API_KEY', } - - # Get API key - api_key = provider_settings.get('api_key') or os.getenv(env_key_mapping.get(provider_type, '')) + + if not api_key: + api_key = provider_settings.get('api_key') or os.getenv(env_key_mapping.get(provider_type, '')) if not api_key: return None @@ -78,7 +96,7 @@ def get_provider_config( ) -def get_default_provider(for_vision: bool = False) -> Optional[LLMConfig]: +def get_default_provider(for_vision: bool = False, user=None) -> Optional[LLMConfig]: """ Get the default (first available) LLM provider configuration. @@ -88,6 +106,7 @@ def get_default_provider(for_vision: bool = False) -> Optional[LLMConfig]: Args: for_vision: If True, use vision-capable model + user: Optional user instance to check per-user config first Returns: LLMConfig for the first available provider, None if none available @@ -97,7 +116,7 @@ def get_default_provider(for_vision: bool = False) -> Optional[LLMConfig]: if default_provider: try: provider_type = LLMProviderType(default_provider) - config = get_provider_config(provider_type, for_vision) + config = get_provider_config(provider_type, for_vision, user=user) if config: return config except ValueError: @@ -110,7 +129,7 @@ def get_default_provider(for_vision: bool = False) -> Optional[LLMConfig]: ] for provider_type in priority_order: - config = get_provider_config(provider_type, for_vision) + config = get_provider_config(provider_type, for_vision, user=user) if config: return config diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d981e7f..8063db1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ # S3-compatible storage "django-storages[s3]>=1.14.0", "boto3>=1.34.0", + "cryptography>=46.0.5", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock index cde4258..9229bbf 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -264,58 +264,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] @@ -821,6 +818,7 @@ source = { virtual = "." } dependencies = [ { name = "boto3" }, { name = "celery", extra = ["redis"] }, + { name = "cryptography" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-cors-headers" }, @@ -863,6 +861,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'" }, { name = "boto3", specifier = ">=1.34.0" }, { name = "celery", extras = ["redis"], specifier = ">=5.3.0" }, + { name = "cryptography", specifier = ">=46.0.5" }, { name = "dj-database-url", specifier = "~=2.1" }, { name = "django", specifier = "~=6.0.1" }, { name = "django-cors-headers", specifier = "~=4.3" }, From 2ad1697ab0970f47752b9277f4ec568cc7e503c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:42:56 +0000 Subject: [PATCH 03/10] feat(frontend): add Models tab to settings for per-user LLM provider configuration - Create llmConfig.js service for LLM config CRUD operations - Add Models tab with CpuChipIcon to settings dialog in ConsoleLayout - Add provider cards for Gemini and OpenRouter with status badges - Add masked API key display for configured providers - Add inline API key update form and delete confirmation dialog - Add i18n translations (EN + zh-CN) for model configuration UI Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- frontend/public/locales/en/translation.json | 25 ++ .../public/locales/zh-CN/translation.json | 25 ++ .../src/components/layout/ConsoleLayout.jsx | 259 ++++++++++++++++++ frontend/src/services/llmConfig.js | 47 ++++ 4 files changed, 356 insertions(+) create mode 100644 frontend/src/services/llmConfig.js diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 68d172b..d1b0dec 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -64,6 +64,7 @@ "language": "Language", "general": "General", "apiKeys": "API keys", + "models": "Models", "apiKeyManagement": { "title": "API key management", "description": "Manage your API keys for MCP and external integrations", @@ -90,6 +91,30 @@ "loadError": "Failed to load API keys", "limitReached": "Maximum limit reached (10)", "keyCount": "{{count}} of 10 keys" + }, + "modelConfig": { + "title": "Model configuration", + "description": "Configure your LLM provider API keys to use AI features with your own accounts", + "provider": "Provider", + "model": "Model", + "apiKey": "API key", + "apiKeyPlaceholder": "Enter your API key", + "save": "Save", + "saving": "Saving...", + "delete": "Remove", + "active": "Active", + "inactive": "Inactive", + "configured": "Configured", + "notConfigured": "Not configured", + "geminiDescription": "Direct access to Google Gemini API", + "openrouterDescription": "Access multiple models via OpenRouter", + "fixedModel": "Gemini 3 Pro (fixed)", + "saveSuccess": "Provider configuration saved successfully", + "saveError": "Failed to save provider configuration", + "deleteSuccess": "Provider configuration removed successfully", + "deleteError": "Failed to remove provider configuration", + "confirmDelete": "Are you sure you want to remove this provider configuration?", + "deleteWarning": "Your API key will be permanently deleted. AI features will fall back to the system default if available." } }, "console": { diff --git a/frontend/public/locales/zh-CN/translation.json b/frontend/public/locales/zh-CN/translation.json index 8fcbb5f..409dbe4 100644 --- a/frontend/public/locales/zh-CN/translation.json +++ b/frontend/public/locales/zh-CN/translation.json @@ -62,6 +62,7 @@ "language": "语言", "general": "通用设置", "apiKeys": "API 密钥", + "models": "模型配置", "apiKeyManagement": { "title": "API 密钥管理", "description": "管理用于 MCP 和外部集成的 API 密钥", @@ -88,6 +89,30 @@ "loadError": "加载 API 密钥失败", "limitReached": "已达到上限(10个)", "keyCount": "{{count}} / 10 个密钥" + }, + "modelConfig": { + "title": "模型配置", + "description": "配置您的 LLM 供应商 API 密钥,使用您自己的账户来访问 AI 功能", + "provider": "供应商", + "model": "模型", + "apiKey": "API 密钥", + "apiKeyPlaceholder": "请输入您的 API 密钥", + "save": "保存", + "saving": "保存中...", + "delete": "移除", + "active": "已启用", + "inactive": "未启用", + "configured": "已配置", + "notConfigured": "未配置", + "geminiDescription": "直接访问 Google Gemini API", + "openrouterDescription": "通过 OpenRouter 访问多种模型", + "fixedModel": "Gemini 3 Pro(固定)", + "saveSuccess": "供应商配置保存成功", + "saveError": "保存供应商配置失败", + "deleteSuccess": "供应商配置已移除", + "deleteError": "移除供应商配置失败", + "confirmDelete": "确定要移除此供应商配置吗?", + "deleteWarning": "您的 API 密钥将被永久删除。AI 功能将回退到系统默认设置(如果可用)。" } }, "console": { diff --git a/frontend/src/components/layout/ConsoleLayout.jsx b/frontend/src/components/layout/ConsoleLayout.jsx index 7092127..9244101 100644 --- a/frontend/src/components/layout/ConsoleLayout.jsx +++ b/frontend/src/components/layout/ConsoleLayout.jsx @@ -12,9 +12,11 @@ import { GlobeAltIcon, CheckIcon, ClipboardDocumentIcon, + CpuChipIcon, } from '@heroicons/react/24/outline' import { ChevronDownIcon } from '@heroicons/react/20/solid' import { fetchAPIKeys, createAPIKey, deleteAPIKey } from '../../services/apiKeys' +import { fetchLLMConfigs, createLLMConfig, updateLLMConfig, deleteLLMConfig } from '../../services/llmConfig' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Badge } from '../ui/badge' @@ -43,11 +45,22 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe const [keyToDelete, setKeyToDelete] = useState(null) const [copiedKeyId, setCopiedKeyId] = useState(null) + // LLM Config state + const [llmConfigs, setLlmConfigs] = useState([]) + const [loadingLLMConfigs, setLoadingLLMConfigs] = useState(false) + const [savingProvider, setSavingProvider] = useState(null) + const [providerApiKeys, setProviderApiKeys] = useState({ gemini: '', openrouter: '' }) + const [showDeleteLLMDialog, setShowDeleteLLMDialog] = useState(false) + const [llmConfigToDelete, setLlmConfigToDelete] = useState(null) + // Load API keys when settings dialog opens and API Keys tab is active useEffect(() => { if (settingsOpen && activeSettingTab === 'apiKeys') { loadAPIKeys() } + if (settingsOpen && activeSettingTab === 'models') { + loadLLMConfigs() + } }, [settingsOpen, activeSettingTab]) const loadAPIKeys = async () => { @@ -118,6 +131,58 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe } } + // LLM Config handlers + const loadLLMConfigs = async () => { + setLoadingLLMConfigs(true) + try { + const data = await fetchLLMConfigs() + const configs = Array.isArray(data) ? data : [] + setLlmConfigs(configs) + setProviderApiKeys({ gemini: '', openrouter: '' }) + } catch (error) { + console.error('Failed to load LLM configs:', error) + setLlmConfigs([]) + } finally { + setLoadingLLMConfigs(false) + } + } + + const handleSaveLLMConfig = async (provider) => { + const apiKey = providerApiKeys[provider] + if (!apiKey.trim()) return + + setSavingProvider(provider) + try { + const existing = llmConfigs.find(c => c.provider === provider) + if (existing) { + await updateLLMConfig(existing.id, { api_key: apiKey }) + } else { + await createLLMConfig({ provider, api_key: apiKey }) + } + await loadLLMConfigs() + } catch (error) { + console.error('Failed to save LLM config:', error) + alert(t('settings.modelConfig.saveError')) + } finally { + setSavingProvider(null) + } + } + + const handleDeleteLLMConfig = async () => { + if (!llmConfigToDelete) return + try { + await deleteLLMConfig(llmConfigToDelete.id) + setShowDeleteLLMDialog(false) + setLlmConfigToDelete(null) + await loadLLMConfigs() + } catch (error) { + console.error('Failed to delete LLM config:', error) + alert(t('settings.modelConfig.deleteError')) + } + } + + const getProviderConfig = (provider) => llmConfigs.find(c => c.provider === provider) + const formatDate = (dateString) => { if (!dateString) return t('settings.apiKeyManagement.never') // Map i18n language codes to locale codes for toLocaleDateString @@ -135,6 +200,7 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe const settingsTabs = [ { id: 'general', name: t('settings.general'), icon: GlobeAltIcon }, + { id: 'models', name: t('settings.models'), icon: CpuChipIcon }, { id: 'apiKeys', name: t('settings.apiKeys'), icon: KeyIcon }, ] @@ -490,6 +556,139 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe )} + {activeSettingTab === 'models' && ( +
+
+

+ {t('settings.modelConfig.description')} +

+
+ + {loadingLLMConfigs ? ( +
+ {t('common.loading')} +
+ ) : ( +
+ {[ + { id: 'gemini', name: 'Gemini', description: t('settings.modelConfig.geminiDescription') }, + { id: 'openrouter', name: 'OpenRouter', description: t('settings.modelConfig.openrouterDescription') }, + ].map((provider) => { + const config = getProviderConfig(provider.id) + return ( +
+
+
+
+

+ {provider.name} +

+ + {config ? t('settings.modelConfig.configured') : t('settings.modelConfig.notConfigured')} + +
+

+ {provider.description} +

+
+ {config && ( + + )} +
+ + + + {/* Model (fixed) */} +
+ +

+ {t('settings.modelConfig.fixedModel')} +

+
+ + {/* API Key */} + {config ? ( +
+ +
+ + {config.api_key_display} + +
+
+ ) : null} + + {/* Update / Set API Key */} +
+ +
+ setProviderApiKeys(prev => ({ + ...prev, + [provider.id]: e.target.value + }))} + placeholder={t('settings.modelConfig.apiKeyPlaceholder')} + className="flex-1 text-sm" + /> + +
+
+
+ ) + })} +
+ )} +
+ )} + {activeSettingTab === 'apiKeys' && (
{/* Header with description and count */} @@ -842,6 +1041,66 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe
+ + {/* Delete LLM Config Confirmation Dialog */} + setShowDeleteLLMDialog(false)} className="relative z-50"> + +
+ +

+ {t('settings.modelConfig.confirmDelete')} +

+ +

+ {t('settings.modelConfig.deleteWarning')} +

+ + {llmConfigToDelete && ( +
+
+ {llmConfigToDelete.provider_display} +
+
+ )} + +
+ + +
+
+
+
) } \ No newline at end of file diff --git a/frontend/src/services/llmConfig.js b/frontend/src/services/llmConfig.js new file mode 100644 index 0000000..e2d4f83 --- /dev/null +++ b/frontend/src/services/llmConfig.js @@ -0,0 +1,47 @@ +import api from '../lib/api'; + +/** + * Service for managing user LLM provider configurations + */ + +/** + * Fetch all LLM configurations for the current user + * @returns {Promise} Promise resolving to array of LLM configs + */ +export const fetchLLMConfigs = async () => { + const response = await api.get('/accounts/llm-configs/'); + return response.data; +}; + +/** + * Create a new LLM provider configuration + * @param {Object} data - LLM config data + * @param {string} data.provider - Provider name (gemini or openrouter) + * @param {string} data.api_key - API key for the provider + * @returns {Promise} Promise resolving to created config + */ +export const createLLMConfig = async (data) => { + const response = await api.post('/accounts/llm-configs/', data); + return response.data; +}; + +/** + * Update an existing LLM provider configuration + * @param {string} configId - UUID of the config to update + * @param {Object} data - Updated fields + * @returns {Promise} Promise resolving to updated config + */ +export const updateLLMConfig = async (configId, data) => { + const response = await api.patch(`/accounts/llm-configs/${configId}/`, data); + return response.data; +}; + +/** + * Delete an LLM provider configuration + * @param {string} configId - UUID of the config to delete + * @returns {Promise} Promise resolving to success message + */ +export const deleteLLMConfig = async (configId) => { + const response = await api.delete(`/accounts/llm-configs/${configId}/`); + return response.data; +}; From 647a779e3be7c98add71ccab192c7594f170fd39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:43:43 +0000 Subject: [PATCH 04/10] fix: remove redundant ternary in model config label Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- frontend/src/components/layout/ConsoleLayout.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/layout/ConsoleLayout.jsx b/frontend/src/components/layout/ConsoleLayout.jsx index 9244101..963df0b 100644 --- a/frontend/src/components/layout/ConsoleLayout.jsx +++ b/frontend/src/components/layout/ConsoleLayout.jsx @@ -655,7 +655,7 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe {/* Update / Set API Key */}
Date: Thu, 26 Feb 2026 06:22:54 +0000 Subject: [PATCH 05/10] feat(accounts): add default provider selection and LLM readiness check - Add is_default field to UserLLMConfig model with single-default enforcement - Add /api/accounts/llm-configs/readiness/ endpoint for pre-flight checks - Update get_default_provider() to prioritize user's explicit default - Add migration for is_default field - Add 6 new tests (default provider, readiness endpoint) Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- .../0004_add_is_default_to_userllmconfig.py | 20 +++++ backend/apps/accounts/models.py | 13 ++++ backend/apps/accounts/serializers.py | 2 +- backend/apps/accounts/tests.py | 76 +++++++++++++++++++ backend/apps/accounts/urls.py | 2 + backend/apps/accounts/views.py | 18 +++++ backend/apps/design_system/llm/config.py | 24 +++++- 7 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 backend/apps/accounts/migrations/0004_add_is_default_to_userllmconfig.py diff --git a/backend/apps/accounts/migrations/0004_add_is_default_to_userllmconfig.py b/backend/apps/accounts/migrations/0004_add_is_default_to_userllmconfig.py new file mode 100644 index 0000000..d4bf8de --- /dev/null +++ b/backend/apps/accounts/migrations/0004_add_is_default_to_userllmconfig.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-02-26 06:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0003_add_userllmconfig"), + ] + + operations = [ + migrations.AddField( + model_name="userllmconfig", + name="is_default", + field=models.BooleanField( + default=False, help_text="Whether this is the default provider for the user", verbose_name="Is Default" + ), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 864da6a..57697f8 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -241,6 +241,11 @@ class UserLLMConfig(TimeStampedModel): verbose_name=_('Is Active'), help_text=_('Whether this provider configuration is active') ) + is_default = models.BooleanField( + default=False, + verbose_name=_('Is Default'), + help_text=_('Whether this is the default provider for the user') + ) class Meta: verbose_name = _('User LLM configuration') @@ -256,6 +261,14 @@ class Meta: def __str__(self): return f'{self.user.email} - {self.get_provider_display()}' + def save(self, *args, **kwargs): + # Ensure only one default config per user + if self.is_default: + UserLLMConfig.objects.filter( + user=self.user, is_default=True + ).exclude(pk=self.pk).update(is_default=False) + super().save(*args, **kwargs) + def set_api_key(self, plaintext_key: str): """Encrypt and store the API key.""" from .encryption import encrypt_value diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index a3cbcc2..9365445 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -149,7 +149,7 @@ class Meta: fields = [ 'id', 'provider', 'provider_display', 'api_key', 'api_key_display', - 'is_active', 'created_at', 'updated_at', + 'is_active', 'is_default', 'created_at', 'updated_at', ] read_only_fields = ['id', 'created_at', 'updated_at'] diff --git a/backend/apps/accounts/tests.py b/backend/apps/accounts/tests.py index a6b9391..3f360de 100644 --- a/backend/apps/accounts/tests.py +++ b/backend/apps/accounts/tests.py @@ -779,6 +779,82 @@ def test_unauthenticated_returns_401(self): response = self.client.get(self.LLM_CONFIGS_URL) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_set_default_provider(self): + """PATCH with is_default=True should mark the provider as default.""" + from apps.accounts.models import UserLLMConfig + cfg = UserLLMConfig(user=self.user, provider="gemini") + cfg.set_api_key("test-key") + cfg.save() + url = f"{self.LLM_CONFIGS_URL}{cfg.pk}/" + response = self.client.patch(url, {"is_default": True}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["is_default"]) + + def test_only_one_default_per_user(self): + """Setting a new default should clear the previous one.""" + from apps.accounts.models import UserLLMConfig + cfg1 = UserLLMConfig(user=self.user, provider="gemini", is_default=True) + cfg1.set_api_key("key-1") + cfg1.save() + cfg2 = UserLLMConfig(user=self.user, provider="openrouter", is_default=False) + cfg2.set_api_key("key-2") + cfg2.save() + url = f"{self.LLM_CONFIGS_URL}{cfg2.pk}/" + self.client.patch(url, {"is_default": True}, format="json") + cfg1.refresh_from_db() + cfg2.refresh_from_db() + self.assertFalse(cfg1.is_default) + self.assertTrue(cfg2.is_default) + + +# --------------------------------------------------------------------------- +# LLM Readiness Check Tests +# --------------------------------------------------------------------------- + +class TestLLMReadinessView(AccountsAPITestBase): + """Tests for the LLM readiness check endpoint.""" + + READINESS_URL = "/api/accounts/llm-configs/readiness/" + + def setUp(self): + super().setUp() + self.user = self._create_user() + self._authenticate(self.user) + + def test_not_ready_when_no_config(self): + """Should return ready=False when no default config exists.""" + response = self.client.get(self.READINESS_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["ready"]) + self.assertIsNone(response.data["default_provider"]) + + def test_ready_when_default_exists(self): + """Should return ready=True when a default config exists.""" + from apps.accounts.models import UserLLMConfig + cfg = UserLLMConfig(user=self.user, provider="gemini", is_default=True) + cfg.set_api_key("test-key") + cfg.save() + response = self.client.get(self.READINESS_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["ready"]) + self.assertEqual(response.data["default_provider"], "gemini") + + def test_not_ready_when_config_exists_but_not_default(self): + """Should return ready=False when config exists but is_default=False.""" + from apps.accounts.models import UserLLMConfig + cfg = UserLLMConfig(user=self.user, provider="gemini", is_default=False) + cfg.set_api_key("test-key") + cfg.save() + response = self.client.get(self.READINESS_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["ready"]) + + def test_unauthenticated_returns_401(self): + """Readiness endpoint should require authentication.""" + self.client.credentials() + response = self.client.get(self.READINESS_URL) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + if __name__ == "__main__": unittest.main() diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index b27b6b1..61d8ea7 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -11,6 +11,7 @@ UserAPIKeyDetailView, UserLLMConfigListCreateView, UserLLMConfigDetailView, + llm_readiness_view, ) app_name = 'accounts' @@ -30,4 +31,5 @@ # LLM Configurations path('llm-configs/', UserLLMConfigListCreateView.as_view(), name='llm-config-list-create'), path('llm-configs//', UserLLMConfigDetailView.as_view(), name='llm-config-detail'), + path('llm-configs/readiness/', llm_readiness_view, name='llm-readiness'), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 395b6b4..a81bbb2 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -229,3 +229,21 @@ def destroy(self, request, *args, **kwargs): return Response({ 'message': _('LLM configuration deleted successfully') }, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def llm_readiness_view(request): + """ + Check whether the authenticated user has a default LLM provider + configured with valid credentials. + """ + default_config = UserLLMConfig.objects.filter( + user=request.user, is_active=True, is_default=True + ).first() + + ready = default_config is not None + return Response({ + 'ready': ready, + 'default_provider': default_config.provider if default_config else None, + }, status=status.HTTP_200_OK) diff --git a/backend/apps/design_system/llm/config.py b/backend/apps/design_system/llm/config.py index fdfb2a6..6744ec1 100644 --- a/backend/apps/design_system/llm/config.py +++ b/backend/apps/design_system/llm/config.py @@ -101,8 +101,9 @@ def get_default_provider(for_vision: bool = False, user=None) -> Optional[LLMCon Get the default (first available) LLM provider configuration. Priority order: - 1. OpenRouter (preferred - supports Gemini 3 Pro with reasoning) - 2. Gemini (direct API) + 1. User's explicitly marked default provider (is_default=True) + 2. OpenRouter (preferred - supports Gemini 3 Pro with reasoning) + 3. Gemini (direct API) Args: for_vision: If True, use vision-capable model @@ -111,7 +112,22 @@ def get_default_provider(for_vision: bool = False, user=None) -> Optional[LLMCon Returns: LLMConfig for the first available provider, None if none available """ - # Check for explicit default provider in settings + # 1. Check user's explicit default provider + if user is not None: + try: + from apps.accounts.models import UserLLMConfig + default_config = UserLLMConfig.objects.filter( + user=user, is_active=True, is_default=True + ).first() + if default_config: + provider_type = LLMProviderType(default_config.provider) + config = get_provider_config(provider_type, for_vision, user=user) + if config: + return config + except Exception: + pass + + # 2. Check for explicit default provider in settings default_provider = getattr(settings, 'DEFAULT_LLM_PROVIDER', None) if default_provider: try: @@ -122,7 +138,7 @@ def get_default_provider(for_vision: bool = False, user=None) -> Optional[LLMCon except ValueError: pass - # Try providers in priority order (OpenRouter first for Gemini 3 Pro support) + # 3. Try providers in priority order (OpenRouter first for Gemini 3 Pro support) priority_order = [ LLMProviderType.OPENROUTER, LLMProviderType.GEMINI, From 4a599d13849a3038f44f2a057e09a26b23632372 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:29:55 +0000 Subject: [PATCH 06/10] feat(frontend): add default provider selection and pre-flight validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Set as default" / "Default" badge to provider cards in Models tab - Add pre-flight LLM readiness check before workspace creation - Show warning banner and auto-open Settings → Models when validation fails - Add checkLLMReadiness() service function - Add i18n translations (EN + zh-CN) for default/readiness UI strings - ConsoleLayout accepts openSettingsTab/onSettingsClosed props Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- frontend/public/locales/en/translation.json | 6 +- .../public/locales/zh-CN/translation.json | 6 +- .../src/components/layout/ConsoleLayout.jsx | 87 +++++++++++++------ frontend/src/pages/DesignWorkshop.jsx | 41 ++++++++- frontend/src/services/llmConfig.js | 9 ++ 5 files changed, 121 insertions(+), 28 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index d1b0dec..ba84579 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -106,6 +106,8 @@ "inactive": "Inactive", "configured": "Configured", "notConfigured": "Not configured", + "default": "Default", + "setDefault": "Set as default", "geminiDescription": "Direct access to Google Gemini API", "openrouterDescription": "Access multiple models via OpenRouter", "fixedModel": "Gemini 3 Pro (fixed)", @@ -114,7 +116,9 @@ "deleteSuccess": "Provider configuration removed successfully", "deleteError": "Failed to remove provider configuration", "confirmDelete": "Are you sure you want to remove this provider configuration?", - "deleteWarning": "Your API key will be permanently deleted. AI features will fall back to the system default if available." + "deleteWarning": "Your API key will be permanently deleted. AI features will fall back to the system default if available.", + "readinessWarning": "Please configure a default model provider before creating a design system.", + "readinessAction": "Configure now" } }, "console": { diff --git a/frontend/public/locales/zh-CN/translation.json b/frontend/public/locales/zh-CN/translation.json index 409dbe4..2f789b0 100644 --- a/frontend/public/locales/zh-CN/translation.json +++ b/frontend/public/locales/zh-CN/translation.json @@ -104,6 +104,8 @@ "inactive": "未启用", "configured": "已配置", "notConfigured": "未配置", + "default": "默认", + "setDefault": "设为默认", "geminiDescription": "直接访问 Google Gemini API", "openrouterDescription": "通过 OpenRouter 访问多种模型", "fixedModel": "Gemini 3 Pro(固定)", @@ -112,7 +114,9 @@ "deleteSuccess": "供应商配置已移除", "deleteError": "移除供应商配置失败", "confirmDelete": "确定要移除此供应商配置吗?", - "deleteWarning": "您的 API 密钥将被永久删除。AI 功能将回退到系统默认设置(如果可用)。" + "deleteWarning": "您的 API 密钥将被永久删除。AI 功能将回退到系统默认设置(如果可用)。", + "readinessWarning": "请在创建设计系统之前配置默认模型供应商。", + "readinessAction": "立即配置" } }, "console": { diff --git a/frontend/src/components/layout/ConsoleLayout.jsx b/frontend/src/components/layout/ConsoleLayout.jsx index 963df0b..a4e8513 100644 --- a/frontend/src/components/layout/ConsoleLayout.jsx +++ b/frontend/src/components/layout/ConsoleLayout.jsx @@ -26,7 +26,7 @@ function classNames(...classes) { return classes.filter(Boolean).join(' ') } -export default function ConsoleLayout({ children, designSystems = [], onCreateNew }) { +export default function ConsoleLayout({ children, designSystems = [], onCreateNew, openSettingsTab = null, onSettingsClosed }) { const { t, i18n } = useTranslation() const { user, logout } = useAuth() const navigate = useNavigate() @@ -53,6 +53,14 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe const [showDeleteLLMDialog, setShowDeleteLLMDialog] = useState(false) const [llmConfigToDelete, setLlmConfigToDelete] = useState(null) + // Allow parent to open settings to a specific tab + useEffect(() => { + if (openSettingsTab) { + setActiveSettingTab(openSettingsTab) + setSettingsOpen(true) + } + }, [openSettingsTab]) + // Load API keys when settings dialog opens and API Keys tab is active useEffect(() => { if (settingsOpen && activeSettingTab === 'apiKeys') { @@ -183,6 +191,15 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe const getProviderConfig = (provider) => llmConfigs.find(c => c.provider === provider) + const handleSetDefault = async (config) => { + try { + await updateLLMConfig(config.id, { is_default: true }) + await loadLLMConfigs() + } catch (error) { + console.error('Failed to set default provider:', error) + } + } + const formatDate = (dateString) => { if (!dateString) return t('settings.apiKeyManagement.never') // Map i18n language codes to locale codes for toLocaleDateString @@ -445,7 +462,7 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe {/* Settings Dialog */} - + { setSettingsOpen(open); if (!open) onSettingsClosed?.() }} className="relative z-50">
{ setSettingsOpen(false) setActiveSettingTab('general') + onSettingsClosed?.() }} className="p-1 rounded-full hover:bg-[#F5F0FF]" style={{ color: 'var(--text-secondary)' }} @@ -593,34 +611,53 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe {config ? t('settings.modelConfig.configured') : t('settings.modelConfig.notConfigured')} + {config?.is_default && ( + + {t('settings.modelConfig.default')} + + )}

{provider.description}

- {config && ( - - )} +
+ {config && !config.is_default && ( + + )} + {config && ( + + )} +
diff --git a/frontend/src/pages/DesignWorkshop.jsx b/frontend/src/pages/DesignWorkshop.jsx index 8760ff5..a7eaed9 100644 --- a/frontend/src/pages/DesignWorkshop.jsx +++ b/frontend/src/pages/DesignWorkshop.jsx @@ -9,6 +9,7 @@ import { Progress } from '@/components/ui/progress' import Pagination from '@/components/ui/Pagination' import CreateVibeModal from '@/components/vibe/CreateVibeModal' import designSystemService, { DesignSystemStatus } from '@/services/designSystem' +import { checkLLMReadiness } from '@/services/llmConfig' const PAGE_SIZE = 10 @@ -24,6 +25,8 @@ export default function DesignWorkshop() { const [totalPages, setTotalPages] = useState(1) const [pollingProgress, setPollingProgress] = useState({}) // { [systemId]: { progress, message } } const pollingRefs = useRef({}) // Store polling timers + const [openSettingsTab, setOpenSettingsTab] = useState(null) + const [showReadinessWarning, setShowReadinessWarning] = useState(false) // Fetch design systems on mount const fetchDesignSystems = useCallback(async (page = 1) => { @@ -143,7 +146,17 @@ export default function DesignWorkshop() { } }, [fetchDesignSystems]) - const handleCreateNew = () => { + const handleCreateNew = async () => { + try { + const { ready } = await checkLLMReadiness() + if (!ready) { + setShowReadinessWarning(true) + setOpenSettingsTab('models') + return + } + } catch { + // If readiness check fails (e.g. network error), allow creation anyway + } setIsCreateModalOpen(true) } @@ -186,8 +199,34 @@ export default function DesignWorkshop() { <> { setOpenSettingsTab(null); setShowReadinessWarning(false) }} >
+ {/* Readiness Warning */} + {showReadinessWarning && ( +
+ {t('settings.modelConfig.readinessWarning')} + +
+ )} + {/* Page Header */}
diff --git a/frontend/src/services/llmConfig.js b/frontend/src/services/llmConfig.js index e2d4f83..5616edc 100644 --- a/frontend/src/services/llmConfig.js +++ b/frontend/src/services/llmConfig.js @@ -45,3 +45,12 @@ export const deleteLLMConfig = async (configId) => { const response = await api.delete(`/accounts/llm-configs/${configId}/`); return response.data; }; + +/** + * Check if the user has a default LLM provider configured + * @returns {Promise} Promise resolving to { ready: boolean, default_provider: string|null } + */ +export const checkLLMReadiness = async () => { + const response = await api.get('/accounts/llm-configs/readiness/'); + return response.data; +}; From c21fec3db1fce636cb7d01348c2e44272fd4dbbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:32:05 +0000 Subject: [PATCH 07/10] fix: address code review - URL ordering, extract handler, add logging - Move readiness URL before uuid pattern to avoid routing conflict - Extract inline onClose to handleCloseSettings function - Add logger.warning to exception catches in LLM config module Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- backend/apps/accounts/urls.py | 2 +- backend/apps/design_system/llm/config.py | 7 +++++-- frontend/src/components/layout/ConsoleLayout.jsx | 7 ++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 61d8ea7..75b1c6f 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -30,6 +30,6 @@ # LLM Configurations path('llm-configs/', UserLLMConfigListCreateView.as_view(), name='llm-config-list-create'), - path('llm-configs//', UserLLMConfigDetailView.as_view(), name='llm-config-detail'), path('llm-configs/readiness/', llm_readiness_view, name='llm-readiness'), + path('llm-configs//', UserLLMConfigDetailView.as_view(), name='llm-config-detail'), ] diff --git a/backend/apps/design_system/llm/config.py b/backend/apps/design_system/llm/config.py index 6744ec1..7e9903a 100644 --- a/backend/apps/design_system/llm/config.py +++ b/backend/apps/design_system/llm/config.py @@ -8,11 +8,14 @@ - Gemini (direct Google API) - OpenRouter (with Gemini 3 Pro and reasoning capabilities) """ +import logging import os from django.conf import settings from typing import Optional from .providers import LLMConfig, LLMProviderType +logger = logging.getLogger(__name__) + # Default model configurations for each provider DEFAULT_MODELS = { @@ -62,7 +65,7 @@ def get_provider_config( if user_config: api_key = user_config.get_api_key() except Exception: - pass + logger.warning('Failed to load per-user LLM config for provider %s', provider_type.value, exc_info=True) # 2. Try Django settings / env vars as fallback llm_settings = getattr(settings, 'LLM_PROVIDERS', {}) @@ -125,7 +128,7 @@ def get_default_provider(for_vision: bool = False, user=None) -> Optional[LLMCon if config: return config except Exception: - pass + logger.warning('Failed to load user default LLM provider', exc_info=True) # 2. Check for explicit default provider in settings default_provider = getattr(settings, 'DEFAULT_LLM_PROVIDER', None) diff --git a/frontend/src/components/layout/ConsoleLayout.jsx b/frontend/src/components/layout/ConsoleLayout.jsx index a4e8513..5d9e57d 100644 --- a/frontend/src/components/layout/ConsoleLayout.jsx +++ b/frontend/src/components/layout/ConsoleLayout.jsx @@ -200,6 +200,11 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe } } + const handleCloseSettings = (open) => { + setSettingsOpen(open) + if (!open) onSettingsClosed?.() + } + const formatDate = (dateString) => { if (!dateString) return t('settings.apiKeyManagement.never') // Map i18n language codes to locale codes for toLocaleDateString @@ -462,7 +467,7 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe {/* Settings Dialog */} - { setSettingsOpen(open); if (!open) onSettingsClosed?.() }} className="relative z-50"> +
Date: Thu, 26 Feb 2026 07:04:52 +0000 Subject: [PATCH 08/10] refactor(frontend): replace delete/set-default buttons with toggle switch for default provider - Replace "Set as default" button and "Delete" button with Headless UI Switch toggle - Toggle is disabled when provider has no API key configured - Only one provider can be ON at a time (mutually exclusive via backend enforcement) - Prevent toggling OFF the current default (must always have one) - Move "Remove" (delete API key) to subtle text link next to masked key - Add aria-label to Remove button for accessibility - Update i18n translations Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- frontend/public/locales/en/translation.json | 2 +- .../public/locales/zh-CN/translation.json | 2 +- .../src/components/layout/ConsoleLayout.jsx | 81 +++++++++---------- 3 files changed, 39 insertions(+), 46 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index ba84579..499bfdc 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -107,7 +107,7 @@ "configured": "Configured", "notConfigured": "Not configured", "default": "Default", - "setDefault": "Set as default", + "setDefault": "Default", "geminiDescription": "Direct access to Google Gemini API", "openrouterDescription": "Access multiple models via OpenRouter", "fixedModel": "Gemini 3 Pro (fixed)", diff --git a/frontend/public/locales/zh-CN/translation.json b/frontend/public/locales/zh-CN/translation.json index 2f789b0..7477d9d 100644 --- a/frontend/public/locales/zh-CN/translation.json +++ b/frontend/public/locales/zh-CN/translation.json @@ -105,7 +105,7 @@ "configured": "已配置", "notConfigured": "未配置", "default": "默认", - "setDefault": "设为默认", + "setDefault": "默认", "geminiDescription": "直接访问 Google Gemini API", "openrouterDescription": "通过 OpenRouter 访问多种模型", "fixedModel": "Gemini 3 Pro(固定)", diff --git a/frontend/src/components/layout/ConsoleLayout.jsx b/frontend/src/components/layout/ConsoleLayout.jsx index 5d9e57d..a67c92a 100644 --- a/frontend/src/components/layout/ConsoleLayout.jsx +++ b/frontend/src/components/layout/ConsoleLayout.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { useAuth } from '../../contexts/AuthContext' -import { Dialog, DialogBackdrop, DialogPanel, TransitionChild, Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react' +import { Dialog, DialogBackdrop, DialogPanel, TransitionChild, Menu, MenuButton, MenuItem, MenuItems, Switch } from '@headlessui/react' import { Bars3Icon, XMarkIcon, @@ -191,12 +191,13 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe const getProviderConfig = (provider) => llmConfigs.find(c => c.provider === provider) - const handleSetDefault = async (config) => { + const handleToggleDefault = async (config, enabled) => { + if (!enabled) return try { await updateLLMConfig(config.id, { is_default: true }) await loadLLMConfigs() } catch (error) { - console.error('Failed to set default provider:', error) + console.error('Failed to toggle default provider:', error) } } @@ -616,52 +617,33 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe {config ? t('settings.modelConfig.configured') : t('settings.modelConfig.notConfigured')} - {config?.is_default && ( - - {t('settings.modelConfig.default')} - - )}

{provider.description}

-
- {config && !config.is_default && ( - - )} - {config && ( - - )} +
+ + {config?.is_default ? t('settings.modelConfig.default') : t('settings.modelConfig.setDefault')} + + config && handleToggleDefault(config, enabled)} + disabled={!config} + className={classNames( + config?.is_default ? 'bg-[var(--accent-mint)]' : 'bg-[var(--bg-canvas)]', + !config ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer', + 'relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border border-[var(--border-default)] transition-colors duration-200 ease-in-out focus:outline-none' + )} + > + +
@@ -690,6 +672,17 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe > {config.api_key_display} +
) : null} From 64d7ec406c3c2f7f43fe76c3f4771c4907bd5e44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:21:34 +0000 Subject: [PATCH 09/10] fix(i18n): remove "(fixed)" suffix from model name in provider cards Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- frontend/public/locales/en/translation.json | 2 +- frontend/public/locales/zh-CN/translation.json | 2 +- frontend/src/components/layout/ConsoleLayout.jsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 499bfdc..aa3759a 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -110,7 +110,7 @@ "setDefault": "Default", "geminiDescription": "Direct access to Google Gemini API", "openrouterDescription": "Access multiple models via OpenRouter", - "fixedModel": "Gemini 3 Pro (fixed)", + "fixedModel": "Gemini 2.5 Pro", "saveSuccess": "Provider configuration saved successfully", "saveError": "Failed to save provider configuration", "deleteSuccess": "Provider configuration removed successfully", diff --git a/frontend/public/locales/zh-CN/translation.json b/frontend/public/locales/zh-CN/translation.json index 7477d9d..4b44632 100644 --- a/frontend/public/locales/zh-CN/translation.json +++ b/frontend/public/locales/zh-CN/translation.json @@ -108,7 +108,7 @@ "setDefault": "默认", "geminiDescription": "直接访问 Google Gemini API", "openrouterDescription": "通过 OpenRouter 访问多种模型", - "fixedModel": "Gemini 3 Pro(固定)", + "fixedModel": "Gemini 2.5 Pro", "saveSuccess": "供应商配置保存成功", "saveError": "保存供应商配置失败", "deleteSuccess": "供应商配置已移除", diff --git a/frontend/src/components/layout/ConsoleLayout.jsx b/frontend/src/components/layout/ConsoleLayout.jsx index a67c92a..292d8af 100644 --- a/frontend/src/components/layout/ConsoleLayout.jsx +++ b/frontend/src/components/layout/ConsoleLayout.jsx @@ -649,7 +649,7 @@ export default function ConsoleLayout({ children, designSystems = [], onCreateNe - {/* Model (fixed) */} + {/* Model */}

From e3b0d53e1720e1c5193bc8df494192e14b64b72c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:55:05 +0000 Subject: [PATCH 10/10] fix(i18n): update model name from Gemini 2.5 Pro to Gemini 3 Pro Co-authored-by: ZhenhangTung <8170159+ZhenhangTung@users.noreply.github.com> --- frontend/public/locales/en/translation.json | 2 +- frontend/public/locales/zh-CN/translation.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index aa3759a..c1a052c 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -110,7 +110,7 @@ "setDefault": "Default", "geminiDescription": "Direct access to Google Gemini API", "openrouterDescription": "Access multiple models via OpenRouter", - "fixedModel": "Gemini 2.5 Pro", + "fixedModel": "Gemini 3 Pro", "saveSuccess": "Provider configuration saved successfully", "saveError": "Failed to save provider configuration", "deleteSuccess": "Provider configuration removed successfully", diff --git a/frontend/public/locales/zh-CN/translation.json b/frontend/public/locales/zh-CN/translation.json index 4b44632..66dfcac 100644 --- a/frontend/public/locales/zh-CN/translation.json +++ b/frontend/public/locales/zh-CN/translation.json @@ -108,7 +108,7 @@ "setDefault": "默认", "geminiDescription": "直接访问 Google Gemini API", "openrouterDescription": "通过 OpenRouter 访问多种模型", - "fixedModel": "Gemini 2.5 Pro", + "fixedModel": "Gemini 3 Pro", "saveSuccess": "供应商配置保存成功", "saveError": "保存供应商配置失败", "deleteSuccess": "供应商配置已移除",