From d1012ceff2ceeec5730dcf37ccf964630411ca7a Mon Sep 17 00:00:00 2001 From: israel Date: Thu, 21 May 2026 23:14:40 +0100 Subject: [PATCH 1/4] feat(platform): provider detail drawer + api-key suffix masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the 1.4k-line provider settings page into a reusable `provider-detail-drawer` component; the route now mounts the drawer. - Add model picker UX: search, "show more" pagination, select-all, add-model confirmation, and a Showing-N-of-M counter. - Mask provider API keys as `start … suffix` (6 chars + 4 chars) so users can match against the vendor dashboard. Adds a new `fetchConfiguredProviderModels` action that uses the stored secret. - Capture the trailing 4 chars of Better Auth API keys at creation time via an after-hook on `/api-key/create`, store them in a new nullable `suffix` column on the `apikey` table, and render keys as `start … suffix` in the API Keys table. Pre-existing rows fall back to prefix-only. - Drop the obsolete `examples/providers/openai.json` and shrink the bundled OpenRouter example. - Translations for de / en / fr. --- examples/providers/openai.json | 66 - examples/providers/openrouter.json | 658 +------ .../components/api-key-revoke-dialog.test.tsx | 1 + .../components/api-key-row-actions.test.tsx | 1 + .../components/api-keys-table.test.tsx | 1 + .../hooks/use-api-keys-table-config.tsx | 14 +- .../app/features/settings/api-keys/types.ts | 10 + .../components/provider-add-panel.tsx | 514 +++--- .../components/provider-detail-drawer.tsx | 1599 +++++++++++++++++ .../providers/components/providers-table.tsx | 41 +- .../settings/providers/hooks/mutations.ts | 6 + .../$id/settings/providers/$providerName.tsx | 1433 +-------------- services/platform/convex/auth.ts | 40 + .../convex/betterAuth/_generated/component.ts | 7 + services/platform/convex/betterAuth/schema.ts | 8 + .../platform/convex/providers/file_actions.ts | 128 +- services/platform/messages/de.json | 11 +- services/platform/messages/en.json | 15 +- services/platform/messages/fr.json | 11 +- 19 files changed, 2177 insertions(+), 2387 deletions(-) delete mode 100644 examples/providers/openai.json create mode 100644 services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx diff --git a/examples/providers/openai.json b/examples/providers/openai.json deleted file mode 100644 index 99234f60d0..0000000000 --- a/examples/providers/openai.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "displayName": "OpenAI", - "description": "OpenAI API. Whisper for speech-to-text and gpt-4o-mini-tts for voice-mode playback. Add chat/vision models here if you want to call them directly rather than via OpenRouter.", - "baseUrl": "https://api.openai.com/v1", - "defaults": { - "transcription": "whisper-1", - "text-to-speech": "gpt-4o-mini-tts" - }, - "models": [ - { - "id": "whisper-1", - "displayName": "Whisper v1", - "description": "Speech-to-text transcription. Billed per minute of audio; 25 MB file ceiling.", - "tags": ["transcription"], - "cost": { "centsPerAudioMinute": 0.6 } - }, - { - "id": "gpt-4o-mini-tts", - "displayName": "GPT-4o mini TTS", - "description": "Low-cost speech synthesis. Six built-in voices, locale-mapped. NOTE: OpenAI bills gpt-4o-mini-tts per token (text in + audio out), NOT per character — the cost field below is a deliberate operator-supplied char-approximation used only by Tale's budget/ledger machinery. The default value matches OpenAI tts-1's published $15/M-char list rate and works as a conservative ceiling, but operators should recalibrate against their own usage mix (CJK and other high-audio-token-per-character scripts under-estimate the most). The full two-component pricing model is tracked as a follow-up.", - "tags": ["text-to-speech"], - "audioFormat": "mp3", - "defaultVoice": "alloy", - "voicesByLocale": { - "en": "alloy", - "de": "nova", - "de-CH": "nova", - "fr": "shimmer" - }, - "defaultInstructions": "Speak in a warm, natural conversational tone with smooth, even pacing. Sound like a friendly person talking, not a news anchor.", - "instructionsByLocale": { - "en": "Speak in a warm, natural conversational tone with smooth, even pacing. Sound like a friendly person talking, not a news anchor.", - "de": "Sprich in einem warmen, natürlichen Gesprächston mit ruhigem, gleichmäßigem Tempo. Klinge wie eine freundliche Person, nicht wie ein Nachrichtensprecher.", - "de-CH": "Sprich in einem warmen, natürlichen Gesprächston mit ruhigem, gleichmäßigem Tempo. Klinge wie eine freundliche Person, nicht wie ein Nachrichtensprecher.", - "fr": "Parle d'une voix chaleureuse et naturelle, avec un rythme régulier et posé. Sois comme une personne amicale qui parle, pas comme un présentateur de journal télévisé." - }, - "cost": { - "centsPerMillionCharacters": 1500 - } - } - ], - "i18n": { - "de": { - "description": "OpenAI-API. Whisper für Speech-to-Text und gpt-4o-mini-tts für die Sprachausgabe. Füge hier Chat-/Vision-Modelle hinzu, wenn du sie direkt statt über OpenRouter aufrufen willst.", - "models": { - "whisper-1": { - "description": "Speech-to-Text-Transkription. Abrechnung pro Audiominute; 25 MB Datei-Obergrenze." - }, - "gpt-4o-mini-tts": { - "description": "Günstige Sprachsynthese. Sechs eingebaute Stimmen, locale-zugeordnet. HINWEIS: OpenAI rechnet gpt-4o-mini-tts pro Token ab (Text-Eingabe + Audio-Ausgabe), NICHT pro Zeichen — das unten konfigurierte cost-Feld ist eine vom Betreiber gepflegte Zeichen-Näherung, die nur von Tales Budget- und Ledger-Logik genutzt wird. Operatoren sollten den Wert gegen ihren realen Nutzungs-Mix kalibrieren (CJK und andere Skripte mit hoher Audio-Token-pro-Zeichen-Rate werden am stärksten unterschätzt)." - } - } - }, - "fr": { - "description": "API OpenAI. Whisper pour la reconnaissance vocale et gpt-4o-mini-tts pour la sortie vocale. Ajoute ici les modèles chat/vision si tu souhaites les appeler directement plutôt que via OpenRouter.", - "models": { - "whisper-1": { - "description": "Transcription audio en texte. Facturation à la minute d'audio ; limite de 25 Mo par fichier." - }, - "gpt-4o-mini-tts": { - "description": "Synthèse vocale économique. Six voix intégrées, mappées par locale. NOTE : OpenAI facture gpt-4o-mini-tts au token (texte en entrée + audio en sortie), PAS au caractère — le champ cost ci-dessous est une approximation char fournie par l'opérateur, utilisée uniquement par la logique budget/ledger de Tale. Les opérateurs devraient recalibrer la valeur en fonction de leur usage réel (les scripts CJK et autres à fort ratio audio-token-par-caractère sont les plus sous-estimés)." - } - } - } - } -} diff --git a/examples/providers/openrouter.json b/examples/providers/openrouter.json index 6976ecb9f2..a3c699f7d9 100644 --- a/examples/providers/openrouter.json +++ b/examples/providers/openrouter.json @@ -1,657 +1,27 @@ { "displayName": "OpenRouter", - "description": "Multi-model AI gateway with access to leading LLM providers", + "description": "OpenRouter description", "baseUrl": "https://openrouter.ai/api/v1", - "supportsStructuredOutputs": true, - "defaults": { - "chat": "deepseek/deepseek-v4-flash", - "vision": "qwen/qwen3-vl-32b-instruct", - "embedding": "qwen/qwen3-embedding-8b" - }, "models": [ { - "id": "anthropic/claude-opus-4.6", - "displayName": "Claude Opus 4.6", - "description": "Anthropic's flagship — best for complex reasoning and coding", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 500, - "outputCentsPerMillion": 2500 - } + "id": "anthropic/claude-opus-4.5", + "displayName": "anthropic/claude-opus-4.5", + "tags": ["chat"] }, { - "id": "anthropic/claude-sonnet-4.6", - "displayName": "Claude Sonnet 4.6", - "description": "Balanced Anthropic model for everyday tasks", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 300, - "outputCentsPerMillion": 1500 - } - }, - { - "id": "anthropic/claude-haiku-4.5", - "displayName": "Claude Haiku 4.5", - "description": "Fast, lightweight Anthropic model", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 100, - "outputCentsPerMillion": 500 - } - }, - { - "id": "openai/gpt-5.2-pro", - "displayName": "GPT-5.2 Pro", - "description": "OpenAI's most advanced model for the hardest reasoning tasks", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 2100, - "outputCentsPerMillion": 16800 - } - }, - { - "id": "openai/gpt-5.2", - "displayName": "GPT-5.2", - "description": "OpenAI's flagship general-purpose model", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 175, - "outputCentsPerMillion": 1400 - } - }, - { - "id": "openai/gpt-5.2-chat", - "displayName": "GPT-5.2 Instant", - "description": "Low-latency GPT-5.2 variant tuned for chat", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 175, - "outputCentsPerMillion": 1400 - } - }, - { - "id": "openai/gpt-oss-120b", - "displayName": "GPT-OSS 120B", - "description": "OpenAI open-weight MoE (117B / 5.1B active), Apache 2.0, 131K context", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 4, - "outputCentsPerMillion": 18 - } - }, - { - "id": "google/gemini-3.1-pro-preview", - "displayName": "Gemini 3 Pro", - "description": "Google's most capable Gemini model", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 200, - "outputCentsPerMillion": 1200 - } - }, - { - "id": "google/gemini-3-flash-preview", - "displayName": "Gemini 3 Flash", - "description": "Fast, efficient Gemini for high-volume use", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 50, - "outputCentsPerMillion": 300 - } - }, - { - "id": "google/gemma-4-31b-it", - "displayName": "Gemma 4 31B IT", - "description": "Google open-weight 31B Instruct, multimodal, 262K context", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 13, - "outputCentsPerMillion": 38 - } - }, - { - "id": "google/gemma-4-26b-a4b-it", - "displayName": "Gemma 4 26B A4B IT", - "description": "Smaller Gemma 4 MoE variant, multimodal", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 6, - "outputCentsPerMillion": 33 - } - }, - { - "id": "google/gemini-2.5-flash-image", - "displayName": "Nano Banana (Gemini 2.5 Flash Image)", - "description": "Gemini 2.5 multimodal image generation and editing with reference-image support", - "tags": ["image-generation", "image-edit"], - "imageGenerationMode": "chat-multimodal", - "cost": { - "imageCentsPerImage": 4 - } - }, - { - "id": "deepseek/deepseek-v4-pro", - "displayName": "DeepSeek V4 Pro", - "description": "DeepSeek flagship open-weight MoE, 1M context", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 44, - "outputCentsPerMillion": 87 - }, - "providerOptions": { - "provider": { "quantizations": ["fp8", "fp4"] } - } - }, - { - "id": "deepseek/deepseek-v4-flash", - "displayName": "DeepSeek V4 Flash", - "description": "Efficiency-optimized DeepSeek V4 MoE", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 14, - "outputCentsPerMillion": 28 - }, - "providerOptions": { - "provider": { "quantizations": ["fp8", "fp4"] } - } - }, - { - "id": "moonshotai/kimi-k2.6", - "displayName": "Kimi K2.6", - "description": "Moonshot K2.6 — multimodal, 262K context", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 75, - "outputCentsPerMillion": 350 - }, - "providerOptions": { - "provider": { "quantizations": ["bf16", "fp8", "fp4"] } - } - }, - { - "id": "moonshotai/kimi-k2.5", - "displayName": "Kimi K2.5", - "description": "High-performance Moonshot general-purpose model", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 44, - "outputCentsPerMillion": 200 - } - }, - { - "id": "minimax/minimax-m2.7", - "displayName": "MiniMax M2.7", - "description": "MiniMax flagship MoE for agentic coding and reasoning", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 30, - "outputCentsPerMillion": 120 - }, - "providerOptions": { - "provider": { "quantizations": ["fp8", "fp4"] } - } - }, - { - "id": "nvidia/nemotron-3-super-120b-a12b", - "displayName": "NVIDIA Nemotron 3 Super", - "description": "NVIDIA frontier open-weight reasoning model", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 9, - "outputCentsPerMillion": 45 - } - }, - { - "id": "qwen/qwen3.6-max-preview", - "displayName": "Qwen3.6 Max Preview", - "description": "Alibaba Qwen flagship preview, 262K context", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 104, - "outputCentsPerMillion": 624 - } - }, - { - "id": "qwen/qwen3.6-plus", - "displayName": "Qwen3.6 Plus", - "description": "Multimodal Qwen with 1M context", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 33, - "outputCentsPerMillion": 195 - } - }, - { - "id": "qwen/qwen3.6-flash", - "displayName": "Qwen3.6 Flash", - "description": "Fastest Qwen 1M-context variant, multimodal", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 25, - "outputCentsPerMillion": 150 - } - }, - { - "id": "qwen/qwen3.6-35b-a3b", - "displayName": "Qwen3.6 35B A3B", - "description": "Compact Qwen3.6 MoE, multimodal", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 15, - "outputCentsPerMillion": 100 - } - }, - { - "id": "qwen/qwen3.5-397b-a17b", - "displayName": "Qwen3.5 397B A17B", - "description": "Qwen3.5 large open-weight MoE (397B / 17B active)", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 39, - "outputCentsPerMillion": 234 - }, - "providerOptions": { - "provider": { "quantizations": ["fp8", "fp4"] } - } - }, - { - "id": "qwen/qwen3-coder", - "displayName": "Qwen3 Coder 480B", - "description": "Qwen3-Coder MoE (480B / 35B active) tuned for agentic coding, 262K context", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 22, - "outputCentsPerMillion": 180 - } - }, - { - "id": "qwen/qwen3-235b-a22b-2507", - "displayName": "Qwen3 235B A22B", - "description": "Qwen3 open-weight MoE (235B / 22B active), 262K context", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 7, - "outputCentsPerMillion": 10 - } - }, - { - "id": "qwen/qwen3-vl-32b-instruct", - "displayName": "Qwen3 VL 32B", - "description": "Qwen vision-language model for image understanding", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 10, - "outputCentsPerMillion": 42 - } - }, - { - "id": "qwen/qwen3-embedding-8b", - "displayName": "Qwen3 Embedding 8B", - "description": "Qwen text embedding model for semantic search", - "tags": ["embedding"], - "dimensions": 1536, - "cost": { - "inputCentsPerMillion": 1, - "outputCentsPerMillion": 0 - } - }, - { - "id": "z-ai/glm-5.1", - "displayName": "GLM 5.1", - "description": "Z.ai GLM 5.1 flagship, 202K context", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 105, - "outputCentsPerMillion": 350 - }, - "providerOptions": { - "provider": { "quantizations": ["fp8", "fp4"] } - } - }, - { - "id": "z-ai/glm-5-turbo", - "displayName": "GLM 5 Turbo", - "description": "Fast 202K-context Z.ai GLM 5", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 120, - "outputCentsPerMillion": 400 - } - }, - { - "id": "z-ai/glm-5v-turbo", - "displayName": "GLM 5V Turbo", - "description": "Multimodal Z.ai GLM 5 Turbo", - "tags": ["chat", "vision"], - "cost": { - "inputCentsPerMillion": 120, - "outputCentsPerMillion": 400 - } - }, - { - "id": "mistralai/mistral-large-2512", - "displayName": "Mistral Large 3", - "description": "Mistral's flagship large model", - "tags": ["chat"], - "maxOutputTokens": 8192, - "cost": { - "inputCentsPerMillion": 50, - "outputCentsPerMillion": 150 - } - }, - { - "id": "mistralai/mistral-medium-3", - "displayName": "Mistral Medium 3", - "description": "Mid-tier Mistral for general tasks", - "tags": ["chat"], - "maxOutputTokens": 8192, - "cost": { - "inputCentsPerMillion": 40, - "outputCentsPerMillion": 200 - } - }, - { - "id": "xiaomi/mimo-v2.5-pro", - "displayName": "MiMo V2.5 Pro", - "description": "Xiaomi MiMo flagship reasoning model", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 100, - "outputCentsPerMillion": 300 - }, - "providerOptions": { - "provider": { "quantizations": ["fp8"] } - } + "id": "anthropic/claude-opus-4.7", + "displayName": "anthropic/claude-opus-4.7", + "tags": ["chat"] }, { - "id": "meta-llama/llama-4-maverick", - "displayName": "LLaMA 4 Maverick", - "description": "Meta's powerful open-source LLM", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 15, - "outputCentsPerMillion": 60 - } - }, - { - "id": "meta-llama/llama-4-scout", - "displayName": "LLaMA 4 Scout", - "description": "Meta's efficient open-source LLM", - "tags": ["chat"], - "cost": { - "inputCentsPerMillion": 8, - "outputCentsPerMillion": 30 - } - }, - { - "id": "black-forest-labs/flux.2-max", - "displayName": "FLUX.2 [max]", - "description": "Top-tier FLUX.2 — highest image quality and editing consistency", - "tags": ["image-generation", "image-edit"], - "imageGenerationMode": "chat-multimodal", - "cost": { - "imageCentsPerImage": 9 - } - }, - { - "id": "black-forest-labs/flux.2-pro", - "displayName": "FLUX.2 [pro]", - "description": "Frontier-level image generation and editing", - "tags": ["image-generation", "image-edit"], - "imageGenerationMode": "chat-multimodal", - "cost": { - "imageCentsPerImage": 6 - } + "id": "anthropic/claude-sonnet-4.6", + "displayName": "anthropic/claude-sonnet-4.6", + "tags": ["chat"] }, { - "id": "black-forest-labs/flux.2-flex", - "displayName": "FLUX.2 [flex]", - "description": "FLUX.2 tuned for typography and multi-reference editing", - "tags": ["image-generation", "image-edit"], - "imageGenerationMode": "chat-multimodal", - "cost": { - "imageCentsPerImage": 4 - } - } - ], - "i18n": { - "de": { - "description": "Multi-Modell-KI-Gateway mit Zugang zu führenden LLM-Anbietern", - "models": { - "anthropic/claude-opus-4.6": { - "description": "Leistungsstärkstes Modell für komplexe Aufgaben und Programmierung" - }, - "anthropic/claude-sonnet-4.6": { - "description": "Ausgewogenes Anthropic-Modell für alltägliche Aufgaben" - }, - "anthropic/claude-haiku-4.5": { - "description": "Schnelles, schlankes Anthropic-Modell" - }, - "openai/gpt-5.2-pro": { - "description": "OpenAIs fortschrittlichstes Modell für anspruchsvollste Denkaufgaben" - }, - "openai/gpt-5.2": { - "description": "OpenAIs Flaggschiff-Allzweckmodell" - }, - "openai/gpt-5.2-chat": { - "description": "GPT-5.2-Variante mit niedriger Latenz, optimiert für Chat" - }, - "openai/gpt-oss-120b": { - "description": "OpenAI open-weight MoE (117B / 5,1B aktiv), Apache 2.0, 131K Kontext" - }, - "google/gemini-3.1-pro-preview": { - "description": "Googles leistungsfähigstes Gemini-Modell" - }, - "google/gemini-3-flash-preview": { - "description": "Schnelles, effizientes Gemini für Massenanwendungen" - }, - "google/gemma-4-31b-it": { - "description": "Google open-weight 31B Instruct, multimodal, 262K Kontext" - }, - "google/gemma-4-26b-a4b-it": { - "description": "Kleinere Gemma-4-MoE-Variante, multimodal" - }, - "google/gemini-2.5-flash-image": { - "description": "Multimodale Gemini-2.5-Bilderzeugung und -bearbeitung mit Referenzbild-Unterstützung" - }, - "deepseek/deepseek-v4-pro": { - "description": "DeepSeeks Flaggschiff open-weight MoE, 1M Kontext" - }, - "deepseek/deepseek-v4-flash": { - "description": "Effizienzoptimiertes DeepSeek-V4-MoE" - }, - "moonshotai/kimi-k2.6": { - "description": "Moonshot K2.6 — multimodal, 262K Kontext" - }, - "moonshotai/kimi-k2.5": { - "description": "Hochleistungs-Allzweckmodell von Moonshot" - }, - "minimax/minimax-m2.7": { - "description": "MiniMax-Flaggschiff-MoE für agentische Programmierung und Reasoning" - }, - "nvidia/nemotron-3-super-120b-a12b": { - "description": "NVIDIAs open-weight-Reasoning-Modell der Spitzenklasse" - }, - "qwen/qwen3.6-max-preview": { - "description": "Alibaba-Qwen-Flaggschiff-Vorschau, 262K Kontext" - }, - "qwen/qwen3.6-plus": { - "description": "Multimodales Qwen mit 1M Kontext" - }, - "qwen/qwen3.6-flash": { - "description": "Schnellste Qwen-Variante mit 1M Kontext, multimodal" - }, - "qwen/qwen3.6-35b-a3b": { - "description": "Kompaktes Qwen3.6-MoE, multimodal" - }, - "qwen/qwen3.5-397b-a17b": { - "description": "Großes open-weight Qwen3.5-MoE (397B / 17B aktiv)" - }, - "qwen/qwen3-coder": { - "description": "Qwen3-Coder-MoE (480B / 35B aktiv), abgestimmt auf agentische Programmierung, 262K Kontext" - }, - "qwen/qwen3-235b-a22b-2507": { - "description": "Qwen3 open-weight MoE (235B / 22B aktiv), 262K Kontext" - }, - "qwen/qwen3-vl-32b-instruct": { - "description": "Qwen-Vision-Language-Modell zum Bildverständnis" - }, - "qwen/qwen3-embedding-8b": { - "description": "Qwen-Text-Embedding-Modell für semantische Suche" - }, - "z-ai/glm-5.1": { - "description": "Z.ai-GLM-5.1-Flaggschiff, 202K Kontext" - }, - "z-ai/glm-5-turbo": { - "description": "Schnelles Z.ai-GLM-5 mit 202K Kontext" - }, - "z-ai/glm-5v-turbo": { - "description": "Multimodales Z.ai-GLM-5-Turbo" - }, - "mistralai/mistral-large-2512": { - "description": "Mistrals Flaggschiff-Großmodell" - }, - "mistralai/mistral-medium-3": { - "description": "Mittelklasse-Mistral für allgemeine Aufgaben" - }, - "xiaomi/mimo-v2.5-pro": { - "description": "Xiaomis Flaggschiff-Reasoning-Modell MiMo" - }, - "meta-llama/llama-4-maverick": { - "description": "Metas leistungsstarkes Open-Source-LLM" - }, - "meta-llama/llama-4-scout": { - "description": "Metas effizientes Open-Source-LLM" - }, - "black-forest-labs/flux.2-max": { - "description": "FLUX.2 der Spitzenklasse — höchste Bildqualität und Bearbeitungskonsistenz" - }, - "black-forest-labs/flux.2-pro": { - "description": "Bilderzeugung und -bearbeitung auf Spitzenniveau" - }, - "black-forest-labs/flux.2-flex": { - "description": "FLUX.2 optimiert für Typografie und Mehrfach-Referenzbearbeitung" - } - } - }, - "fr": { - "description": "Passerelle IA multi-modèles avec accès aux principaux fournisseurs de LLM", - "models": { - "anthropic/claude-opus-4.6": { - "description": "Le modèle phare d'Anthropic — idéal pour le raisonnement complexe et le code" - }, - "anthropic/claude-sonnet-4.6": { - "description": "Modèle Anthropic équilibré pour les tâches courantes" - }, - "anthropic/claude-haiku-4.5": { - "description": "Modèle Anthropic rapide et léger" - }, - "openai/gpt-5.2-pro": { - "description": "Le modèle le plus avancé d'OpenAI pour les tâches de raisonnement les plus exigeantes" - }, - "openai/gpt-5.2": { - "description": "Modèle généraliste phare d'OpenAI" - }, - "openai/gpt-5.2-chat": { - "description": "Variante GPT-5.2 à faible latence optimisée pour le chat" - }, - "openai/gpt-oss-120b": { - "description": "MoE open-weight d'OpenAI (117B / 5,1B actifs), Apache 2.0, contexte 131K" - }, - "google/gemini-3.1-pro-preview": { - "description": "Le modèle Gemini le plus performant de Google" - }, - "google/gemini-3-flash-preview": { - "description": "Gemini rapide et efficace pour les usages à fort volume" - }, - "google/gemma-4-31b-it": { - "description": "Google 31B Instruct open-weight, multimodal, contexte 262K" - }, - "google/gemma-4-26b-a4b-it": { - "description": "Variante MoE plus petite de Gemma 4, multimodale" - }, - "google/gemini-2.5-flash-image": { - "description": "Génération et édition d'images multimodales Gemini 2.5 avec prise en charge des images de référence" - }, - "deepseek/deepseek-v4-pro": { - "description": "MoE open-weight phare de DeepSeek, contexte 1M" - }, - "deepseek/deepseek-v4-flash": { - "description": "MoE DeepSeek V4 optimisé pour l'efficacité" - }, - "moonshotai/kimi-k2.6": { - "description": "Moonshot K2.6 — multimodal, contexte 262K" - }, - "moonshotai/kimi-k2.5": { - "description": "Modèle généraliste haute performance de Moonshot" - }, - "minimax/minimax-m2.7": { - "description": "MoE phare de MiniMax pour le code agentique et le raisonnement" - }, - "nvidia/nemotron-3-super-120b-a12b": { - "description": "Modèle de raisonnement open-weight de pointe de NVIDIA" - }, - "qwen/qwen3.6-max-preview": { - "description": "Aperçu phare d'Alibaba Qwen, contexte 262K" - }, - "qwen/qwen3.6-plus": { - "description": "Qwen multimodal avec contexte 1M" - }, - "qwen/qwen3.6-flash": { - "description": "La variante Qwen la plus rapide à contexte 1M, multimodale" - }, - "qwen/qwen3.6-35b-a3b": { - "description": "MoE Qwen3.6 compact, multimodal" - }, - "qwen/qwen3.5-397b-a17b": { - "description": "Grand MoE open-weight Qwen3.5 (397B / 17B actifs)" - }, - "qwen/qwen3-coder": { - "description": "MoE Qwen3-Coder (480B / 35B actifs) optimisé pour le code agentique, contexte 262K" - }, - "qwen/qwen3-235b-a22b-2507": { - "description": "MoE Qwen3 open-weight (235B / 22B actifs), contexte 262K" - }, - "qwen/qwen3-vl-32b-instruct": { - "description": "Modèle vision-langage Qwen pour la compréhension d'images" - }, - "qwen/qwen3-embedding-8b": { - "description": "Modèle d'embedding texte Qwen pour la recherche sémantique" - }, - "z-ai/glm-5.1": { - "description": "Modèle phare Z.ai GLM 5.1, contexte 202K" - }, - "z-ai/glm-5-turbo": { - "description": "Z.ai GLM 5 rapide à contexte 202K" - }, - "z-ai/glm-5v-turbo": { - "description": "Z.ai GLM 5 Turbo multimodal" - }, - "mistralai/mistral-large-2512": { - "description": "Grand modèle phare de Mistral" - }, - "mistralai/mistral-medium-3": { - "description": "Mistral milieu de gamme pour les tâches générales" - }, - "xiaomi/mimo-v2.5-pro": { - "description": "Modèle de raisonnement phare MiMo de Xiaomi" - }, - "meta-llama/llama-4-maverick": { - "description": "LLM open-source puissant de Meta" - }, - "meta-llama/llama-4-scout": { - "description": "LLM open-source efficace de Meta" - }, - "black-forest-labs/flux.2-max": { - "description": "FLUX.2 haut de gamme — qualité d'image et cohérence d'édition maximales" - }, - "black-forest-labs/flux.2-pro": { - "description": "Génération et édition d'images au niveau de pointe" - }, - "black-forest-labs/flux.2-flex": { - "description": "FLUX.2 optimisé pour la typographie et l'édition multi-références" - } - } + "id": "anthropic/claude-opus-4.7-fast", + "displayName": "anthropic/claude-opus-4.7-fast", + "tags": ["chat"] } - } + ] } diff --git a/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.test.tsx b/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.test.tsx index 918267b449..99c380e595 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.test.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.test.tsx @@ -20,6 +20,7 @@ function makeApiKey(overrides: Partial = {}): ApiKey { name: 'Test Key', start: 'tale_abc', prefix: 'tale_', + suffix: 'wxyz', userId: 'user-1', enabled: true, expiresAt: null, diff --git a/services/platform/app/features/settings/api-keys/components/api-key-row-actions.test.tsx b/services/platform/app/features/settings/api-keys/components/api-key-row-actions.test.tsx index 8ce176b600..9f4d36217c 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-row-actions.test.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-row-actions.test.tsx @@ -20,6 +20,7 @@ function makeApiKey(overrides: Partial = {}): ApiKey { name: 'Test Key', start: 'tale_abc', prefix: 'tale_', + suffix: 'wxyz', userId: 'user-1', enabled: true, expiresAt: null, diff --git a/services/platform/app/features/settings/api-keys/components/api-keys-table.test.tsx b/services/platform/app/features/settings/api-keys/components/api-keys-table.test.tsx index 331e7bd5bc..3166ea3afe 100644 --- a/services/platform/app/features/settings/api-keys/components/api-keys-table.test.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-keys-table.test.tsx @@ -35,6 +35,7 @@ function makeApiKey(overrides: Partial = {}): ApiKey { name: 'Test Key', start: 'tale_abc', prefix: 'tale_', + suffix: 'wxyz', userId: 'user-1', enabled: true, expiresAt: null, diff --git a/services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx b/services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx index 4ab44f84d3..0d0e8e28da 100644 --- a/services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx +++ b/services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx @@ -38,11 +38,15 @@ export function useApiKeysTableConfig( { id: 'key', header: tSettings('apiKeys.columns.key'), - cell: ({ row }) => ( - - {row.original.start || row.original.prefix || '-'} - - ), + cell: ({ row }) => { + const head = row.original.start || row.original.prefix; + const tail = row.original.suffix; + return ( + + {head ? (tail ? `${head} … ${tail}` : head) : '-'} + + ); + }, }, { id: 'created', diff --git a/services/platform/app/features/settings/api-keys/types.ts b/services/platform/app/features/settings/api-keys/types.ts index 6edd98990b..86c54182a1 100644 --- a/services/platform/app/features/settings/api-keys/types.ts +++ b/services/platform/app/features/settings/api-keys/types.ts @@ -13,6 +13,16 @@ export interface ApiKey { * Used as fallback when `start` is not available. */ prefix: string | null; + /** + * Trailing plaintext characters of the key, captured at creation time + * by an after-hook on `/api-key/create` (the upstream Better Auth + * plugin doesn't know about this column). Rendered alongside `start` + * as `start … suffix` so users can match a row against the key they + * hold. Optional because (a) Better Auth's SDK return type doesn't + * include it and (b) rows created before this feature shipped have no + * value — those render with the prefix only. + */ + suffix?: string | null; userId?: string; enabled: boolean | null; expiresAt: Date | null; diff --git a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx index 5ec40568b9..f5525c6520 100644 --- a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx +++ b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx @@ -6,7 +6,7 @@ import { Button } from '@tale/ui/button'; import { IconButton } from '@tale/ui/icon-button'; import { useNavigate } from '@tanstack/react-router'; import { Loader2, Pencil, Plus, RefreshCw, Trash2, X } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod/v4'; @@ -78,10 +78,14 @@ export function ProviderAddPanel({ // Fetched model IDs from the provider endpoint const [fetchedModels, setFetchedModels] = useState([]); - const [selectedModelIds, setSelectedModelIds] = useState(new Set()); const [fetchError, setFetchError] = useState(null); const [hasFetched, setHasFetched] = useState(false); const [modelSearch, setModelSearch] = useState(''); + // Paginate the visible model rows. Default cap mirrors the design — show + // a handful of rows, then expose "Show more" so users can expand in chunks + // without scrolling through hundreds of fetched models. + const PAGE_SIZE = 10; + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const formSchema = useMemo( () => @@ -181,6 +185,29 @@ export function ProviderAddPanel({ }); const watchedModels = watch('models'); + const fetchCredsBaseUrl = watch('baseUrl'); + const fetchCredsApiKey = watch('apiKey'); + + // Snapshot of the (baseUrl, apiKey) the last fetch ran against — when the + // current form values drift from this, the cached fetched list belongs to + // different credentials, so we clear it and re-expose the Fetch button. + const [fetchedCredentials, setFetchedCredentials] = useState<{ + baseUrl: string; + apiKey: string; + } | null>(null); + + useEffect(() => { + if (!fetchedCredentials) return; + if ( + fetchCredsBaseUrl !== fetchedCredentials.baseUrl || + fetchCredsApiKey !== fetchedCredentials.apiKey + ) { + setFetchedModels([]); + setHasFetched(false); + setFetchError(null); + setFetchedCredentials(null); + } + }, [fetchCredsBaseUrl, fetchCredsApiKey, fetchedCredentials]); // ── Fetch models from provider ────────────────────────────────────── @@ -193,90 +220,96 @@ export function ProviderAddPanel({ const result = await fetchModels({ orgSlug, baseUrl, apiKey }); const ids = result.map((m) => m.id); setFetchedModels(ids); - // Auto-select all fetched models, excluding any already added manually - const existingIds = new Set(watchedModels.map((m) => m.id)); - setSelectedModelIds(new Set(ids.filter((id) => !existingIds.has(id)))); + // Fetched models default to UNCHECKED. Selecting a row IS the add + // action, so we don't pre-append anything to the form here. setHasFetched(true); + setFetchedCredentials({ baseUrl, apiKey }); } catch (error) { console.error('Failed to fetch models:', error); setFetchError(t('providers.fetchModelsError')); setHasFetched(false); + setFetchedCredentials(null); } - }, [fetchModels, getValues, orgSlug, watchedModels, t]); - - const handleToggleModel = useCallback((modelId: string, checked: boolean) => { - setSelectedModelIds((prev) => { - const next = new Set(prev); - if (checked) { - next.add(modelId); - } else { - next.delete(modelId); + }, [fetchModels, getValues, orgSlug, t]); + + // A fetched model row's checkbox toggles its presence in the form. + const handleToggleFetchedModel = useCallback( + (modelId: string, checked: boolean) => { + const idx = watchedModels.findIndex((m) => m.id === modelId); + if (checked && idx === -1) { + append({ id: modelId, displayName: modelId, tags: ['chat'] }); + } else if (!checked && idx !== -1) { + remove(idx); } - return next; - }); - }, []); + }, + [watchedModels, append, remove], + ); - // Add selected fetched models to the form's models array - const handleAddSelectedModels = useCallback(() => { - const existingIds = new Set(watchedModels.map((m) => m.id)); - for (const id of selectedModelIds) { - if (!existingIds.has(id)) { - append({ id, displayName: displayNameFromId(id), tags: ['chat'] }); - } - } - // Clear fetch state after adding - setFetchedModels([]); - setSelectedModelIds(new Set()); - setHasFetched(false); - }, [selectedModelIds, watchedModels, append]); - - // Filter fetched models to only show ones not already added - const availableFetchedModels = useMemo(() => { - const existingIds = new Set(watchedModels.map((m) => m.id)); - return fetchedModels.filter((id) => !existingIds.has(id)); + // Unified row list: fetched models (in provider order) followed by any + // manually-added models. Rows know which source they came from so the UI + // can render a checkbox (fetched) or trash (manual) accordingly. + type RowEntry = { + id: string; + source: 'fetched' | 'manual'; + formIndex: number | null; + }; + const rows = useMemo(() => { + const addedById = new Map(); + watchedModels.forEach((m, i) => addedById.set(m.id, i)); + const fetchedSet = new Set(fetchedModels); + const fetchedRows: RowEntry[] = fetchedModels.map((id) => ({ + id, + source: 'fetched', + formIndex: addedById.get(id) ?? null, + })); + const manualRows: RowEntry[] = watchedModels + .map((m, i): RowEntry | null => + fetchedSet.has(m.id) + ? null + : { id: m.id, source: 'manual', formIndex: i }, + ) + .filter((r): r is RowEntry => r !== null); + return [...fetchedRows, ...manualRows]; }, [fetchedModels, watchedModels]); - // Further filter by search query - const filteredFetchedModels = useMemo(() => { - if (!modelSearch.trim()) return availableFetchedModels; - const query = modelSearch.toLowerCase().trim(); - return availableFetchedModels.filter((id) => - id.toLowerCase().includes(query), - ); - }, [availableFetchedModels, modelSearch]); - - // Tri-state checkbox: checked | unchecked | indeterminate - const allCheckboxState = useMemo((): boolean | 'indeterminate' => { - if (filteredFetchedModels.length === 0) return false; - const selectedCount = filteredFetchedModels.filter((id) => - selectedModelIds.has(id), - ).length; - if (selectedCount === 0) return false; - if (selectedCount === filteredFetchedModels.length) return true; - return 'indeterminate'; - }, [filteredFetchedModels, selectedModelIds]); - - const handleToggleAllModels = useCallback( - (checked: boolean) => { - if (checked) { - setSelectedModelIds((prev) => { - const next = new Set(prev); - for (const id of filteredFetchedModels) { - next.add(id); - } - return next; - }); - } else { - setSelectedModelIds((prev) => { - const next = new Set(prev); - for (const id of filteredFetchedModels) { - next.delete(id); - } - return next; - }); - } - }, - [filteredFetchedModels], + const filteredRows = useMemo(() => { + const base = !modelSearch.trim() + ? rows + : (() => { + const query = modelSearch.toLowerCase().trim(); + return rows.filter((r) => { + const model = + r.formIndex != null ? watchedModels[r.formIndex] : null; + const haystack = [r.id, model?.displayName ?? ''] + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); + })(); + // Selected (added) rows float to the top so toggling a checkbox visibly + // promotes the row. Stable sort preserves the natural fetched/manual + // ordering within each group, so the list doesn't reshuffle arbitrarily. + return base + .map((row, idx) => ({ row, idx })) + .sort((a, b) => { + const aSelected = a.row.formIndex != null ? 0 : 1; + const bSelected = b.row.formIndex != null ? 0 : 1; + if (aSelected !== bSelected) return aSelected - bSelected; + return a.idx - b.idx; + }) + .map((entry) => entry.row); + }, [rows, modelSearch, watchedModels]); + + // Reset pagination whenever the filtered set changes shape — a new search + // query or a fresh fetch should land the user at the top of the list, not + // at whatever expanded cap they had on the previous result set. + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [modelSearch, fetchedModels]); + + const visibleRows = useMemo( + () => filteredRows.slice(0, visibleCount), + [filteredRows, visibleCount], ); // ── Manual add/edit dialog ────────────────────────────────────────── @@ -375,10 +408,10 @@ export function ProviderAddPanel({ if (!isOpen) { reset(); setFetchedModels([]); - setSelectedModelIds(new Set()); setFetchError(null); setHasFetched(false); setModelSearch(''); + setVisibleCount(PAGE_SIZE); } onOpenChange(isOpen); }, @@ -491,6 +524,19 @@ export function ProviderAddPanel({ watchedApiKey.length > 0 && z.string().url().safeParse(watchedBaseUrl).success; + // Auto-fetch models once credentials look valid. Debounced so we don't + // hammer the endpoint while the user is still typing the key. Only fires + // when we haven't fetched yet for this credential pair — the + // fetchedCredentials cleanup effect above clears `hasFetched` if either + // field changes, so a user fixing a typo will naturally re-trigger this. + useEffect(() => { + if (!canFetch || hasFetched) return; + const handle = setTimeout(() => { + void handleFetchModels(); + }, 500); + return () => clearTimeout(handle); + }, [canFetch, hasFetched, handleFetchModels]); + return (
- {!(hasFetched && availableFetchedModels.length > 0) && ( + {!(hasFetched && rows.length > 0) && ( {t('providers.models')} - {hasFetched && availableFetchedModels.length > 0 ? ( - - ) : ( - + + {canFetch && ( - - - )} + )} + + {fetchError && ( @@ -647,110 +668,163 @@ export function ProviderAddPanel({ )} - {/* Fetched models checklist */} - {hasFetched && availableFetchedModels.length > 0 && ( - - - - handleToggleAllModels(checked === true) - } - /> -
- setModelSearch(e.target.value)} - placeholder={t('providers.searchModels')} - /> -
-
-
- - {filteredFetchedModels.map((modelId) => ( - - ))} - -
- {selectedModelIds.size > 0 && ( - - )} -
+ {isFetching && rows.length === 0 && ( +
+ + + {t('providers.fetchingModels')} + +
)} - {hasFetched && - availableFetchedModels.length === 0 && - fetchedModels.length > 0 && ( - - {t('providers.modelsSelected', { - count: watchedModels.length, - })} - - )} - - {/* Added models list */} - {fields.length > 0 && ( - - {fields.map((field, index) => ( -
- - - - {watchedModels[index]?.id} - - - - {watchedModels[index]?.displayName} + {rows.length > 0 && ( +
+
+ setModelSearch(e.target.value)} + placeholder={t('providers.searchModels')} + className="h-6 w-full bg-transparent ring-0! ring-transparent!" + /> +
+ + {filteredRows.length === 0 && ( +
+ + {t('providers.modelsEmpty.searchTitle')} + + + {t('providers.modelsEmpty.searchDescription')} + +
+ )} + {visibleRows.map((row, rowIdx) => { + const model = + row.formIndex != null + ? watchedModels[row.formIndex] + : null; + const [primaryTag, ...restTags] = model?.tags ?? []; + const overflowCount = restTags.length; + const isLast = + rowIdx === visibleRows.length - 1 && + visibleRows.length === filteredRows.length; + const isAdded = row.formIndex != null; + return ( + + + {row.source === 'fetched' && ( + + handleToggleFetchedModel( + row.id, + checked === true, + ) + } + aria-label={ + isAdded + ? t('providers.removeModel') + : t('providers.addModel') + } + /> + )} + + {model?.displayName ?? row.id} - {watchedModels[index]?.tags.map((tag) => ( - - {modelTagLabel(tag, t)} - - ))} -
- - openEditDialog(index)} - /> - remove(index)} - /> + + {isAdded && primaryTag && ( + + + {modelTagLabel(primaryTag, t)} + + {overflowCount > 0 && ( + modelTagLabel(tag, t)) + .join(', ')} + className="bg-muted text-muted-foreground border-transparent px-1.5 py-0.5 text-[11px]" + > + +{overflowCount} + + )} + + )} + {isAdded && row.formIndex != null && ( + openEditDialog(row.formIndex!)} + /> + )} + {row.source === 'manual' && + row.formIndex != null && ( + remove(row.formIndex!)} + /> + )} + - -
- ))} -
+ ); + })} + + {filteredRows.length > PAGE_SIZE && ( + + + {t('providers.showingModels', { + filtered: visibleRows.length, + total: filteredRows.length, + })} + + {visibleRows.length < filteredRows.length && ( + + )} + + )} +
)}
diff --git a/services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx b/services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx new file mode 100644 index 0000000000..69b58f935c --- /dev/null +++ b/services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx @@ -0,0 +1,1599 @@ +'use client'; + +import { Badge } from '@tale/ui/badge'; +import { Button } from '@tale/ui/button'; +import { IconButton } from '@tale/ui/icon-button'; +import { Skeleton } from '@tale/ui/skeleton'; +import { + AlertTriangle, + Layers, + Loader2, + Pencil, + Plus, + RefreshCw, + Trash2, + X, + Zap, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog'; +import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { Alert } from '@/app/components/ui/feedback/alert'; +import { EmptyState } from '@/app/components/ui/feedback/empty-state'; +import { Checkbox } from '@/app/components/ui/forms/checkbox'; +import { Input } from '@/app/components/ui/forms/input'; +import { SearchInput } from '@/app/components/ui/forms/search-input'; +import { Select } from '@/app/components/ui/forms/select'; +import { Textarea } from '@/app/components/ui/forms/textarea'; +import { Card } from '@/app/components/ui/layout/card'; +import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { Sheet } from '@/app/components/ui/overlays/sheet'; +import { Tooltip } from '@/app/components/ui/overlays/tooltip'; +import { Text } from '@/app/components/ui/typography/text'; +import { useOrganization } from '@/app/features/organization/hooks/queries'; +import { toast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; +import { modelTagLiterals } from '@/lib/shared/schemas/providers'; +import { cn } from '@/lib/utils/cn'; + +import { + useFetchConfiguredProviderModels, + useSaveProviderSecret, +} from '../hooks/mutations'; +import { useHasProviderSecret, useReadProvider } from '../hooks/queries'; +import { + ProviderConfigProvider, + useProviderConfig, +} from '../hooks/use-provider-config-context'; +import { + dispatchForbiddenDeveloperSettings, + dispatchVersionConflict, + readConvexErrorData, +} from '../utils/error-dispatch'; +import { modelTagLabel } from '../utils/model-tag-label'; +import { ProviderDefaultModelsPanel } from './provider-default-models-panel'; +import { ProviderEditPanel } from './provider-edit-panel'; +import { + ModelProviderOptionsField, + ProviderOptionsEditor, + providerOptionsToJsonString, +} from './provider-options-editor'; +import { TestConnectionSheet } from './test-connection-sheet'; + +interface ProviderDetailDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + providerName: string; +} + +export function ProviderDetailDrawer({ + open, + onOpenChange, + organizationId, + providerName, +}: ProviderDetailDrawerProps) { + const { t } = useT('settings'); + const { t: tCommon } = useT('common'); + const { data: organization, isLoading: isOrgLoading } = + useOrganization(organizationId); + const orgSlug = organization?.slug ?? ''; + const enabled = open && !!orgSlug; + const { data, isLoading } = useReadProvider(orgSlug, providerName, { + enabled, + }); + const { data: maskedKey, error: secretError } = useHasProviderSecret( + orgSlug, + providerName, + { enabled }, + ); + + const errorData = readConvexErrorData(secretError); + const encryptedNoKey = errorData?.code === 'PROVIDER_SECRET_ENCRYPTED_NO_KEY'; + const encryptedNoKeyPath = + encryptedNoKey && typeof errorData?.path === 'string' ? errorData.path : ''; + + return ( + + + + {t('providers.details')} + + onOpenChange(false)} + /> + + +
+ {encryptedNoKey && ( +
+ +
+ )} + + {isOrgLoading || isLoading ? ( + + ) : !data?.ok ? ( + + + {t('providers.providerNotFound', { name: providerName })} + + + ) : ( + + + + )} +
+
+ ); +} + +function ProviderDetailSkeleton() { + return ( + + + + + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + + + + ))} + + + + + + + + + + + + + + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + + + + ))} + + + + + + + + + + + + ); +} + +function ProviderDetailBody({ + organizationId, + orgSlug, + providerName, + maskedKey, + maskedModelKeys, +}: { + organizationId: string; + orgSlug: string; + providerName: string; + maskedKey: string | null; + maskedModelKeys: Record; +}) { + return ( + + + + + + + + ); +} + +function InfoRow({ + label, + children, + muted, + isLast, +}: { + label: string; + children: React.ReactNode; + muted?: boolean; + isLast?: boolean; +}) { + return ( + + + {label} + +
+ {children} +
+
+ ); +} + +function SectionHeader({ + title, + description, + onEdit, + editLabel, +}: { + title: string; + description?: string; + onEdit: () => void; + editLabel: string; +}) { + return ( + + + + {title} + + {description && ( + + {description} + + )} + + + + ); +} + +function GeneralSection({ + providerName, + organizationId, +}: { + providerName: string; + organizationId: string; +}) { + const { t } = useT('settings'); + const { config } = useProviderConfig(); + const [panelOpen, setPanelOpen] = useState(false); + + return ( + <> + + setPanelOpen(true)} + editLabel={t('providers.editGeneral')} + /> + + {config.displayName} + + + {config.description || '—'} + + + {config.baseUrl} + + + + + + ); +} + +function DefaultModelsSection({ + organizationId, + providerName, +}: { + organizationId: string; + providerName: string; +}) { + const { t } = useT('settings'); + const { config } = useProviderConfig(); + const [panelOpen, setPanelOpen] = useState(false); + + const modelDisplayName = useCallback( + (modelId: string | undefined) => { + if (!modelId) return '—'; + return ( + config.models.find((m) => m.id === modelId)?.displayName ?? modelId + ); + }, + [config.models], + ); + + return ( + <> + + setPanelOpen(true)} + editLabel={t('providers.editDefaults')} + /> + + {modelDisplayName(config.defaults?.chat)} + + + {modelDisplayName(config.defaults?.vision)} + + + {modelDisplayName(config.defaults?.embedding)} + + + {modelDisplayName(config.defaults?.['image-generation'])} + + + {modelDisplayName(config.defaults?.transcription)} + + + + + + ); +} + +function ProviderOptionsSection() { + const { t } = useT('settings'); + const { t: tCommon } = useT('common'); + const { config, isSaving, saveConfig } = useProviderConfig(); + + return ( + { + await saveConfig({ providerOptions: parsed }); + }} + copy={{ + title: t('providers.providerOptions.providerLevelTitle'), + description: t('providers.providerOptions.providerLevelDescription'), + notConfigured: t('providers.providerOptions.notConfigured'), + editLabel: t('providers.editGeneral'), + saveLabel: t('providers.providerOptions.save'), + cancelLabel: tCommon('actions.cancel'), + saveSuccess: t('providers.providerOptions.saveSuccess'), + saveError: t('providers.providerOptions.saveError'), + exampleLabel: t('providers.providerOptions.exampleLabel'), + discardConfirmTitle: t('providers.providerOptions.discardConfirmTitle'), + discardConfirmDescription: t( + 'providers.providerOptions.discardConfirmDescription', + ), + discardConfirmAction: t( + 'providers.providerOptions.discardConfirmAction', + ), + discardConfirmKeep: t('providers.providerOptions.discardConfirmKeep'), + objectRequiredError: t('providers.providerOptions.objectRequiredError'), + }} + /> + ); +} + +function ApiKeySection({ + orgSlug, + providerName, + maskedKey, +}: { + orgSlug: string; + providerName: string; + maskedKey: string | null; +}) { + const { t } = useT('settings'); + const hasSecret = maskedKey != null; + const saveSecret = useSaveProviderSecret(); + const [dialogOpen, setDialogOpen] = useState(false); + const [testDialogOpen, setTestDialogOpen] = useState(false); + const [apiKey, setApiKey] = useState(''); + const [saving, setSaving] = useState(false); + const [overwritePrompt, setOverwritePrompt] = useState<{ + kind: 'encrypted_no_key' | 'undecryptable_existing'; + path: string; + reason?: string; + } | null>(null); + const apiKeyInputRef = useRef(null); + + const performSave = useCallback( + async (force: boolean) => { + if (!apiKey.trim() || !orgSlug) return; + setSaving(true); + try { + await saveSecret.mutateAsync({ + orgSlug, + providerName, + apiKey: apiKey.trim(), + force: force || undefined, + }); + setApiKey(''); + setDialogOpen(false); + setOverwritePrompt(null); + toast({ + title: t('providers.apiKeyUpdated'), + variant: 'success', + }); + } catch (err) { + const data = readConvexErrorData(err); + if ( + data?.code === 'PROVIDER_SECRET_REFUSED_OVERWRITE' && + (data.kind === 'encrypted_no_key' || + data.kind === 'undecryptable_existing') + ) { + setOverwritePrompt({ + kind: data.kind, + path: typeof data.path === 'string' ? data.path : '', + reason: typeof data.reason === 'string' ? data.reason : undefined, + }); + } else { + setOverwritePrompt(null); + if (!dispatchForbiddenDeveloperSettings(err, t)) { + toast({ + title: t('providers.secretSaveFailed'), + variant: 'destructive', + }); + } + } + } finally { + setSaving(false); + } + }, + [apiKey, orgSlug, providerName, saveSecret, t], + ); + + const handleSaveKey = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + await performSave(false); + }, + [performSave], + ); + + const handleConfirmOverwrite = useCallback(() => { + void performSave(true); + }, [performSave]); + + return ( + <> + + setDialogOpen(true)} + editLabel={hasSecret ? t('providers.editKey') : t('providers.addKey')} + > + + + {hasSecret ? ( + + + {t('providers.apiKeyConfigured')} + + + {maskedKey} + + + ) : ( + + + {t('providers.apiKeyNotConfigured')} + + + )} + + + { + setDialogOpen(open); + if (!open) setApiKey(''); + }} + title={ + hasSecret ? t('providers.replaceApiKey') : t('providers.addApiKey') + } + onSubmit={handleSaveKey} + isSubmitting={saving} + isValid={apiKey.trim().length > 0} + submitText={t('providers.saveKey')} + submittingText={t('providers.saving')} + > + {hasSecret && ( + + {t('providers.replaceApiKeyDescription', { + maskedKey: maskedKey ?? '', + })} + + )} + setApiKey(e.target.value)} + /> + + + + + { + if (!open) setOverwritePrompt(null); + }} + title={t('providers.overwriteUnreadableTitle')} + description={ + overwritePrompt + ? overwritePrompt.kind === 'encrypted_no_key' + ? t('providers.overwriteEncryptedNoKeyDescription', { + path: overwritePrompt.path, + }) + : t('providers.overwriteUndecryptableDescription', { + path: overwritePrompt.path, + reason: overwritePrompt.reason ?? '', + }) + : '' + } + confirmText={t('providers.overwriteAnywayConfirm')} + variant="destructive" + isLoading={saving} + onConfirm={handleConfirmOverwrite} + /> + + ); +} + +interface ModelFormState { + id: string; + displayName: string; + description: string; + tags: string[]; + dimensions: string; + inputCostPerMillion: string; + outputCostPerMillion: string; + imageCostPerImage: string; + imageGenerationMode: '' | 'images-api' | 'chat-multimodal'; + baseUrl: string; + apiKey: string; + providerOptionsJson: string; +} + +const EMPTY_MODEL_FORM: ModelFormState = { + id: '', + displayName: '', + description: '', + tags: ['chat'], + dimensions: '', + inputCostPerMillion: '', + outputCostPerMillion: '', + imageCostPerImage: '', + imageGenerationMode: '', + baseUrl: '', + apiKey: '', + providerOptionsJson: '', +}; + +function ModelsSection({ + orgSlug, + providerName, + maskedModelKeys, +}: { + orgSlug: string; + providerName: string; + maskedModelKeys: Record; +}) { + const { t } = useT('settings'); + const { t: tCommon } = useT('common'); + const { config, saveConfig, isSaving } = useProviderConfig(); + const saveSecret = useSaveProviderSecret(); + const { + mutateAsync: fetchProviderModels, + isPending: isFetchingFromProvider, + } = useFetchConfiguredProviderModels(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [form, setForm] = useState(EMPTY_MODEL_FORM); + const [initialForm, setInitialForm] = useState(EMPTY_MODEL_FORM); + const [deleteIndex, setDeleteIndex] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [savingSecret, setSavingSecret] = useState(false); + const [modelKeyAction, setModelKeyAction] = useState< + 'none' | 'remove' | 'replace' + >('none'); + const modelIdInputRef = useRef(null); + + // Fetched-but-not-yet-configured model IDs from the provider's /models + // endpoint. Configured models live in config.models — this list holds the + // delta the user can opt into via checkbox. + const [fetchedModelIds, setFetchedModelIds] = useState([]); + const [fetchError, setFetchError] = useState(null); + const [hasFetched, setHasFetched] = useState(false); + const [confirmAddModel, setConfirmAddModel] = useState(null); + const PAGE_SIZE = 10; + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + const handleFetchFromProvider = useCallback(async () => { + setFetchError(null); + try { + const result = await fetchProviderModels({ orgSlug, providerName }); + setFetchedModelIds(result.map((m) => m.id)); + setHasFetched(true); + } catch (err) { + console.error('Failed to fetch provider models:', err); + setFetchError(t('providers.fetchModelsError')); + // Mark as "attempted" even on failure so the trash gating doesn't keep + // pretending we have no information. With hasFetched=true + empty + // fetchedModelIds, every configured model reads as manual, which is + // the same fallback as before any fetch — but importantly, the loading + // spinner stops and the user can retry via the Fetch button. + setHasFetched(true); + } + }, [fetchProviderModels, orgSlug, providerName, t]); + + // Quick-add a fetched model: same shape as openAddDialog → submit, but + // skipping the dialog. Defaults to a 'chat' tag matching the add-panel + // behavior; the user can edit metadata afterwards via the pencil button. + const quickAddFetchedModel = useCallback( + async (modelId: string) => { + const updatedModels = [ + ...config.models, + { + id: modelId, + displayName: modelId, + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 'chat' is a valid modelTagLiterals member + tags: ['chat'] as Array<(typeof modelTagLiterals)[number]>, + }, + ]; + try { + await saveConfig({ models: updatedModels }); + } catch (err) { + if (dispatchForbiddenDeveloperSettings(err, t)) return; + if (dispatchVersionConflict(err, t)) return; + toast({ title: t('providers.saveFailed'), variant: 'destructive' }); + } + }, + [config.models, saveConfig, t], + ); + + const openAddDialog = useCallback(() => { + setEditingIndex(null); + setForm(EMPTY_MODEL_FORM); + setInitialForm(EMPTY_MODEL_FORM); + setModelKeyAction('none'); + setDialogOpen(true); + }, []); + + const openEditDialog = useCallback( + (index: number) => { + const model = config.models[index]; + if (!model) return; + setEditingIndex(index); + const formData: ModelFormState = { + id: model.id, + displayName: model.displayName, + description: model.description ?? '', + tags: [...model.tags], + dimensions: model.dimensions != null ? String(model.dimensions) : '', + inputCostPerMillion: + model.cost?.inputCentsPerMillion != null + ? String(model.cost.inputCentsPerMillion / 100) + : '', + outputCostPerMillion: + model.cost?.outputCentsPerMillion != null + ? String(model.cost.outputCentsPerMillion / 100) + : '', + imageCostPerImage: + model.cost?.imageCentsPerImage != null + ? String(model.cost.imageCentsPerImage / 100) + : '', + imageGenerationMode: model.imageGenerationMode ?? '', + baseUrl: model.baseUrl ?? '', + apiKey: '', + providerOptionsJson: providerOptionsToJsonString(model.providerOptions), + }; + setForm(formData); + setInitialForm(formData); + setModelKeyAction('none'); + setDialogOpen(true); + }, + [config.models], + ); + + const handleSubmitModel = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const hasTokenCost = + !!form.inputCostPerMillion || !!form.outputCostPerMillion; + const hasImageCost = !!form.imageCostPerImage; + const cost = + hasTokenCost || hasImageCost + ? { + ...(hasTokenCost + ? { + inputCentsPerMillion: form.inputCostPerMillion + ? Math.round(Number(form.inputCostPerMillion) * 100) + : 0, + outputCentsPerMillion: form.outputCostPerMillion + ? Math.round(Number(form.outputCostPerMillion) * 100) + : 0, + } + : {}), + ...(hasImageCost + ? { + imageCentsPerImage: Math.round( + Number(form.imageCostPerImage) * 100, + ), + } + : {}), + } + : undefined; + const isImageGen = form.tags.includes('image-generation'); + let providerOptions: Record | undefined; + const trimmedProviderOptions = form.providerOptionsJson.trim(); + if (trimmedProviderOptions) { + try { + const parsed: unknown = JSON.parse(trimmedProviderOptions); + if ( + parsed != null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime checks above narrow `parsed` to a non-null, non-array plain object; TS can't track the narrowing across JSON.parse + const obj = parsed as Record; + if (Object.keys(obj).length > 0) { + providerOptions = obj; + } + } + } catch (parseErr) { + toast({ + title: t('providers.providerOptions.invalidJson'), + description: + parseErr instanceof Error ? parseErr.message : String(parseErr), + variant: 'destructive', + }); + return; + } + } + const model = { + id: form.id, + displayName: form.displayName, + description: form.description || undefined, + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- tags are constrained to modelTagLiterals values + tags: form.tags as Array<(typeof modelTagLiterals)[number]>, + dimensions: form.dimensions ? Number(form.dimensions) : undefined, + imageGenerationMode: + isImageGen && form.imageGenerationMode + ? form.imageGenerationMode + : undefined, + baseUrl: form.baseUrl.trim() || undefined, + cost, + providerOptions, + }; + const updatedModels = + editingIndex != null + ? config.models.map((m, i) => (i === editingIndex ? model : m)) + : [...config.models, model]; + try { + await saveConfig({ models: updatedModels }); + if ((form.apiKey.trim() || modelKeyAction === 'remove') && orgSlug) { + setSavingSecret(true); + try { + await saveSecret.mutateAsync({ + orgSlug, + providerName, + modelKeys: { + [form.id]: + modelKeyAction === 'remove' ? '' : form.apiKey.trim(), + }, + }); + } finally { + setSavingSecret(false); + } + } + setDialogOpen(false); + } catch (err) { + if (dispatchForbiddenDeveloperSettings(err, t)) return; + if (dispatchVersionConflict(err, t)) return; + toast({ title: t('providers.saveFailed'), variant: 'destructive' }); + } + }, + [ + form, + editingIndex, + config.models, + saveConfig, + saveSecret, + orgSlug, + providerName, + modelKeyAction, + t, + ], + ); + + const handleDeleteModel = useCallback(async () => { + if (deleteIndex == null) return; + const deletedModel = config.models[deleteIndex]; + try { + const cleanedDefaults: Record = {}; + if (config.defaults) { + for (const [k, v] of Object.entries(config.defaults)) { + if (v !== undefined && v !== deletedModel?.id) { + cleanedDefaults[k] = v; + } + } + } + const cleanedDefaultsOrUndef = + Object.keys(cleanedDefaults).length > 0 ? cleanedDefaults : undefined; + await saveConfig({ + models: config.models.filter((_, i) => i !== deleteIndex), + defaults: cleanedDefaultsOrUndef, + }); + if (deletedModel && orgSlug) { + await saveSecret.mutateAsync({ + orgSlug, + providerName, + modelKeys: { [deletedModel.id]: '' }, + }); + } + setDeleteIndex(null); + } catch (err) { + if (dispatchForbiddenDeveloperSettings(err, t)) return; + if (dispatchVersionConflict(err, t)) return; + toast({ title: t('providers.saveFailed'), variant: 'destructive' }); + } + }, [ + deleteIndex, + config.models, + config.defaults, + saveConfig, + saveSecret, + orgSlug, + providerName, + t, + ]); + + // Unified row list mirroring the add-panel layout: every configured model + // sits alongside any fetched-but-not-yet-configured model IDs. Configured + // rows render the existing edit/delete actions; fetched-only rows expose + // a checkbox that triggers the confirm-and-add flow. + type ModelRow = + | { + source: 'configured'; + id: string; + configuredIndex: number; + } + | { + source: 'fetched'; + id: string; + configuredIndex: null; + }; + const rows = useMemo(() => { + const configuredIds = new Set(config.models.map((m) => m.id)); + const configuredRows: ModelRow[] = config.models.map((m, i) => ({ + source: 'configured', + id: m.id, + configuredIndex: i, + })); + const fetchedRows: ModelRow[] = fetchedModelIds + .filter((id) => !configuredIds.has(id)) + .map((id) => ({ source: 'fetched', id, configuredIndex: null })); + return [...configuredRows, ...fetchedRows]; + }, [config.models, fetchedModelIds]); + + const filteredRows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const base = !query + ? rows + : rows.filter((row) => { + const model = + row.configuredIndex != null + ? config.models[row.configuredIndex] + : null; + const haystack = [ + row.id, + model?.displayName ?? '', + model?.description ?? '', + ...(model?.tags.map((tag) => modelTagLabel(tag, t)) ?? []), + ...(model?.tags ?? []), + ] + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); + // Configured (selected) rows float to top, preserving original order + // within each group. Matches the add-panel toggle behavior. + return base + .map((row, idx) => ({ row, idx })) + .sort((a, b) => { + const aSel = a.row.source === 'configured' ? 0 : 1; + const bSel = b.row.source === 'configured' ? 0 : 1; + if (aSel !== bSel) return aSel - bSel; + return a.idx - b.idx; + }) + .map((entry) => entry.row); + }, [rows, searchQuery, config.models, t]); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [searchQuery, fetchedModelIds]); + + const visibleRows = useMemo( + () => filteredRows.slice(0, visibleCount), + [filteredRows, visibleCount], + ); + + const fetchedModelIdSet = useMemo( + () => new Set(fetchedModelIds), + [fetchedModelIds], + ); + + return ( + <> + + + + {t('providers.models')} + + + + + + + + {fetchError && ( + + {fetchError} + + )} + + {config.models.length === 0 && fetchedModelIds.length === 0 ? ( +
+ + {t('providers.addModelShort')} + + } + /> +
+ ) : ( +
+
+ setSearchQuery(e.target.value)} + placeholder={t('providers.searchModels')} + className="h-6 w-full bg-transparent ring-0! ring-transparent!" + /> +
+ + {filteredRows.length === 0 && ( +
+ + {t('providers.modelsEmpty.searchTitle')} + + + {t('providers.modelsEmpty.searchDescription')} + +
+ )} + {visibleRows.map((row, rowIdx) => { + const model = + row.configuredIndex != null + ? config.models[row.configuredIndex] + : null; + const [primaryTag, ...restTags] = model?.tags ?? []; + const overflowCount = restTags.length; + const isLast = + rowIdx === visibleRows.length - 1 && + visibleRows.length === filteredRows.length; + const isConfigured = row.source === 'configured'; + // A configured model is "manual" if a successful fetch didn't + // surface its ID. If the fetch hasn't succeeded yet, we can't + // tell — treat as manual so the trash stays available. + const isManual = + isConfigured && + (!hasFetched || !fetchedModelIdSet.has(row.id)); + return ( + + + { + if (checked === true && !isConfigured) { + setConfirmAddModel(row.id); + } else if ( + checked === false && + isConfigured && + row.configuredIndex != null + ) { + setDeleteIndex(row.configuredIndex); + } + }} + aria-label={ + isConfigured + ? t('providers.removeModel') + : t('providers.addModel') + } + /> + + {model?.displayName ?? row.id} + + + + {isConfigured && primaryTag && ( + + + {modelTagLabel(primaryTag, t)} + + {overflowCount > 0 && ( + modelTagLabel(tag, t)) + .join(', ')} + > + + +{overflowCount} + + + )} + + )} + {isConfigured && row.configuredIndex != null && ( + + openEditDialog(row.configuredIndex as number) + } + /> + )} + {isManual && row.configuredIndex != null && ( + + setDeleteIndex(row.configuredIndex as number) + } + /> + )} + + + ); + })} +
+ {filteredRows.length > PAGE_SIZE && ( + + + {t('providers.showingModels', { + filtered: visibleRows.length, + total: filteredRows.length, + })} + + {visibleRows.length < filteredRows.length && ( + + )} + + )} +
+ )} +
+ + { + setDialogOpen(open); + if (!open) setForm(EMPTY_MODEL_FORM); + }} + title={ + editingIndex != null + ? t('providers.editModel') + : t('providers.addModel') + } + size="md" + hideClose + className="flex flex-col gap-0 p-0" + onOpenAutoFocus={(e) => { + e.preventDefault(); + requestAnimationFrame(() => modelIdInputRef.current?.focus()); + }} + > + + + {editingIndex != null + ? t('providers.editModel') + : t('providers.addModel')} + + { + setDialogOpen(false); + setForm(EMPTY_MODEL_FORM); + }} + /> + + +
+
+ + setForm((f) => ({ ...f, id: e.target.value }))} + placeholder={t('providers.modelIdPlaceholder')} + /> + + setForm((f) => ({ ...f, displayName: e.target.value })) + } + placeholder={t('providers.modelDisplayNamePlaceholder')} + /> +