feat(platform): provider detail drawer + api-key suffix masking#1729
Conversation
- 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.
📝 WalkthroughWalkthroughThis PR consolidates provider management functionality by introducing an API key suffix tracking feature, creating a new provider detail drawer component, refactoring the provider model selection UI with unified search and pagination, and simplifying navigation via deep-linking. The backend gains a new action to fetch provider model catalogs, and example configurations are updated. Translations are added across multiple languages for the enhanced model management UI. Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/providers/openrouter.json`:
- Around line 17-19: Update the tags for the provider entry with id
"anthropic/claude-sonnet-4.6": change the tags array from ["chat"] to include
"vision" (e.g., ["chat","vision"]) so the model is correctly marked as
multimodal/vision-capable; locate the provider object with id
"anthropic/claude-sonnet-4.6" and update its "tags" property accordingly.
In
`@services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx`:
- Around line 42-47: The rendering currently sets head = row.original.start ||
row.original.prefix and hides the suffix when both start and prefix are missing;
change the fallback so that if head is falsy but row.original.suffix exists we
still render the suffix (e.g., show just the tail or "… {tail}") instead of '-'
— update the component in use-api-keys-table-config.tsx where head and tail are
computed and the JSX return so the conditional becomes: if head show (head +
optional tail), else if tail show tail (or a prefixed ellipsis + tail), else
show '-'. This ensures row.original.suffix is used when start/prefix are absent.
In
`@services/platform/app/features/settings/providers/components/provider-add-panel.tsx`:
- Around line 227-231: The fetch loop re-triggers after an error because
hasFetched remains false; change the catch block in the provider model fetch
logic to setHasFetched(true) (and keep setFetchError and
setFetchedCredentials(null)) so a failed fetch stops the debounce auto-retry but
still allows manual retry via the existing button; ensure the effect that clears
the gate on credential edits still runs when credential fields change (the same
effect that calls setHasFetched(false) on input changes) so user edits re-enable
fetching.
In
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`:
- Around line 873-889: The current flow calls saveConfig(...) before
saveSecret.mutateAsync(...), which can leave config persisted if the secret
write fails; change the logic so both writes are atomic: either call
saveSecret.mutateAsync(...) first and only call saveConfig({ models:
updatedModels }) if the secret write succeeds, or perform both operations via a
single backend endpoint that applies both changes transactionally; if you cannot
change the backend, implement a compensating rollback by catching errors from
saveSecret.mutateAsync and reverting the saved config (using saveConfig with the
previous models snapshot) inside the catch block; update relevant symbols:
saveConfig, saveSecret.mutateAsync, setSavingSecret, form, modelKeyAction,
orgSlug, providerName, and updatedModels.
- Around line 1374-1418: The new cost input labels/placeholders are hardcoded;
update provider-detail-drawer.tsx to use the translation hook (useT) for all
user-facing strings in the Input components for inputCostPerMillion,
outputCostPerMillion, and imageCostPerImage (labels and placeholders), and any
conditional text for the image-generation branch; replace the literal strings
("Input cost (USD / 1M tokens)", "Output cost (USD / 1M tokens)", "Cost per
image (USD)", "e.g., 2.50", "e.g., 10.00", "e.g., 0.06") with t('your.key')
calls from useT(namespace) so the HStack/Input controls render translated text
across locales while keeping existing props (value, onChange, min, step) intact.
- Around line 288-323: SectionHeader's props currently omit children so passed
nested content from ApiKeySection isn't rendered; add children?: React.ReactNode
to the SectionHeader props signature and include {children} in the returned JSX
(for example next to or instead of the existing edit button as appropriate) so
nested header actions passed into <SectionHeader> are displayed; update the
function parameter destructuring to accept children and ensure the component
still types onEdit, title, description, and editLabel as before.
- Around line 315-322: The action buttons in ProviderDetailDrawer use
conflicting focus-visible classes that suppress the outline; remove the
`focus-visible:outline-none` token from the className strings for the
edit/test/undo/remove buttons so the `focus-visible:outline-ring`,
`focus-visible:outline-2` and `focus-visible:outline-offset-2` rules can take
effect — locate the buttons tied to handlers like onEdit, onTest (or test
action), onUndo and onRemove in the ProviderDetailDrawer component and delete
`focus-visible:outline-none` from their className values.
In `@services/platform/convex/providers/file_actions.ts`:
- Around line 1163-1168: Wrap the decrypt/parse sequence that uses
resolveProviderSecretsPath, decryptSecretsFile and parseProviderSecrets in a
try/catch and normalize any thrown errors into a provider-level error with the
PROVIDER_FETCH_FAILED code; specifically, catch errors from
decryptSecretsFile(...) and parseProviderSecrets(...), then throw a new error
(or return the expected error object) that sets/uses PROVIDER_FETCH_FAILED as
the error code/message and attach the original error as a cause or inner detail
for diagnostics so low-level decrypt/parse failures do not leak raw errors to
callers.
In `@services/platform/messages/de.json`:
- Line 1158: Update the translation value for the key "apiKeyUpdated" to use the
informal German pronoun and sentence case; replace the current string "Ihr
API-Schlüssel wurde erfolgreich gespeichert." with "Dein API-Schlüssel wurde
erfolgreich gespeichert." so it matches the project's informal register (du)
used elsewhere.
In `@services/platform/messages/fr.json`:
- Line 1158: Update the French translation value for the key "apiKeyUpdated" to
use informal register and sentence case: replace the formal "Votre" with the
informal "Ta" so the message reads "Ta clé API a été enregistrée avec succès."
Ensure you only modify the string value for the "apiKeyUpdated" key.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b850dfd2-c2e9-45af-8a37-10daf3efb0d2
⛔ Files ignored due to path filters (1)
services/platform/convex/betterAuth/_generated/component.tsis excluded by!**/_generated/**
📒 Files selected for processing (18)
examples/providers/openai.jsonexamples/providers/openrouter.jsonservices/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.test.tsxservices/platform/app/features/settings/api-keys/components/api-key-row-actions.test.tsxservices/platform/app/features/settings/api-keys/components/api-keys-table.test.tsxservices/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsxservices/platform/app/features/settings/api-keys/types.tsservices/platform/app/features/settings/providers/components/provider-add-panel.tsxservices/platform/app/features/settings/providers/components/provider-detail-drawer.tsxservices/platform/app/features/settings/providers/components/providers-table.tsxservices/platform/app/features/settings/providers/hooks/mutations.tsservices/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsxservices/platform/convex/auth.tsservices/platform/convex/betterAuth/schema.tsservices/platform/convex/providers/file_actions.tsservices/platform/messages/de.jsonservices/platform/messages/en.jsonservices/platform/messages/fr.json
💤 Files with no reviewable changes (1)
- examples/providers/openai.json
| "id": "anthropic/claude-sonnet-4.6", | ||
| "displayName": "anthropic/claude-sonnet-4.6", | ||
| "tags": ["chat"] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the relevant section around the referenced lines
echo "=== File: examples/providers/openrouter.json (lines 1-60) ==="
nl -ba examples/providers/openrouter.json | sed -n '1,80p'
echo "=== Occurrences of claude-sonnet-4.6 ==="
rg -n "anthropic/claude-sonnet-4\.6" -S examples/providers/openrouter.jsonRepository: tale-project/tale
Length of output: 184
🌐 Web query:
OpenRouter "claude-sonnet-4.6" vision-models collection
💡 Result:
OpenRouter “claude-sonnet-4.6” corresponds to the model id: anthropic/claude-sonnet-4.6, and it is surfaced under OpenRouter’s Anthropic Claude Sonnet 4.6 model page (a model “collection” in the sense of the OpenRouter model listing, not a separate API endpoint/collection identifier). [1][2] How to call it (model id) 1) Use model: "anthropic/claude-sonnet-4.6". [1] 2) Example (OpenRouter Node SDK) from OpenRouter’s page for this model: - openrouter.chat.send({ model: "anthropic/claude-sonnet-4.6", messages: [...] }) [1] Vision support OpenRouter’s model page for anthropic/claude-sonnet-4.6 describes it as a multimodal model (“Image” is listed alongside “Text” as supported input/output modalities). [2] Model behavior notes that affect vision requests For Claude 4.6 Opus/Sonnet, OpenRouter uses adaptive thinking by default; the migration guide shows how to set thinking via reasoning in requests. This can matter if you are testing/benchmarking outputs, including vision-grounded tasks. [3]
Citations:
- 1: https://openrouter.ai/anthropic/claude-sonnet-4.6
- 2: https://openrouter.ai/anthropic/claude-sonnet-4.6/api
- 3: https://openrouter.ai/docs/cookbook/evaluate-and-optimize/model-migrations/claude-4-6
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("examples/providers/openrouter.json")
text = path.read_text(encoding="utf-8").splitlines()
def show(start, end):
for i in range(start, min(end, len(text))):
print(f"{i+1:4d}: {text[i]}")
print("=== File head (1-60) ===")
show(1, 60)
print("\n=== Find claude-sonnet-4.6 and surrounding lines ===")
for idx, line in enumerate(text):
if "anthropic/claude-sonnet-4.6" in line:
s = max(0, idx-5); e = min(len(text), idx+10)
show(s+1, e)
PY
echo "=== rg occurrences ==="
rg -n "anthropic/claude-sonnet-4\.6" examples/providers/openrouter.jsonRepository: tale-project/tale
Length of output: 1902
🌐 Web query:
site:openrouter.ai "anthropic/claude-sonnet-4.6" vision multimodal image text
💡 Result:
Anthropic Claude Sonnet 4.6 (model id: anthropic/claude-sonnet-4.6) is a multimodal “vision-capable” model via OpenRouter, meaning you can include image inputs (and text) in the same request for image understanding (description/OCR-style analysis). OpenRouter describes multimodal image input support using the chat completions API with image content blocks (image_url) and notes that models are automatically filtered based on modality support; image requests go to vision-capable models [1]. How to send an image + text to a compatible model (OpenRouter unified API style) 1) Use /api/v1/chat/completions. 2) Put the text prompt and then image(s) in the messages content array using image_url content type (URL or base64 data URIs are supported) [1][2]. 3) OpenRouter requires image-capable/vision-compatible models for image processing; it filters models based on modalities present in your request [1]. Relevant API schema details for Anthropic message creation (OpenRouter’s Anthropic-compatible wrapper) OpenRouter’s Anthropic message creation docs include image MIME types (image/jpeg, image/png, image/gif, image/webp) and image block parameters that support base64 or URL sources [3]. Caveat I did not find an official Anthropic platform document in the retrieved sources explicitly stating “Claude Sonnet 4.6 supports vision”; however, OpenRouter’s multimodal docs clearly state how to send images to vision-capable models and how model compatibility is determined by modality support [1]. To confirm 100% for your specific endpoint/provider setup, check that your model listing includes vision/image input support (OpenRouter filters automatically) [1].
Citations:
- 1: https://openrouter.ai/docs/guides/overview/multimodal/overview
- 2: https://openrouter.ai/docs/guides/overview/multimodal/image-understanding
- 3: https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-messages.mdx
Add the vision tag to anthropic/claude-sonnet-4.6 (OpenRouter)
OpenRouter treats Claude Sonnet 4.6 as multimodal/vision-capable, so labeling it as ["chat"] misrepresents capabilities.
Suggested fix
- "tags": ["chat"]
+ "tags": ["chat", "vision"]📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "id": "anthropic/claude-sonnet-4.6", | |
| "displayName": "anthropic/claude-sonnet-4.6", | |
| "tags": ["chat"] | |
| "id": "anthropic/claude-sonnet-4.6", | |
| "displayName": "anthropic/claude-sonnet-4.6", | |
| "tags": ["chat", "vision"] |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@examples/providers/openrouter.json` around lines 17 - 19, Update the tags for
the provider entry with id "anthropic/claude-sonnet-4.6": change the tags array
from ["chat"] to include "vision" (e.g., ["chat","vision"]) so the model is
correctly marked as multimodal/vision-capable; locate the provider object with
id "anthropic/claude-sonnet-4.6" and update its "tags" property accordingly.
| const head = row.original.start || row.original.prefix; | ||
| const tail = row.original.suffix; | ||
| return ( | ||
| <Text as="span" variant="muted" className="font-mono text-sm"> | ||
| {head ? (tail ? `${head} … ${tail}` : head) : '-'} | ||
| </Text> |
There was a problem hiding this comment.
Handle suffix-only fallback in key rendering.
If start and prefix are missing but suffix exists, the current branch shows - and hides usable key identity data.
Proposed fix
- {head ? (tail ? `${head} … ${tail}` : head) : '-'}
+ {head
+ ? tail
+ ? `${head} … ${tail}`
+ : head
+ : tail
+ ? `… ${tail}`
+ : '-'}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx`
around lines 42 - 47, The rendering currently sets head = row.original.start ||
row.original.prefix and hides the suffix when both start and prefix are missing;
change the fallback so that if head is falsy but row.original.suffix exists we
still render the suffix (e.g., show just the tail or "… {tail}") instead of '-'
— update the component in use-api-keys-table-config.tsx where head and tail are
computed and the JSX return so the conditional becomes: if head show (head +
optional tail), else if tail show tail (or a prefixed ellipsis + tail), else
show '-'. This ensures row.original.suffix is used when start/prefix are absent.
| } catch (error) { | ||
| console.error('Failed to fetch models:', error); | ||
| setFetchError(t('providers.fetchModelsError')); | ||
| setHasFetched(false); | ||
| setFetchedCredentials(null); |
There was a problem hiding this comment.
Stop auto-fetching after the first failed attempt.
On any fetch error, hasFetched stays false, so the debounce effect schedules another request 500ms later. Invalid credentials or a transient outage will loop until the user edits a field or closes the panel, repeatedly hitting the provider endpoint and spamming logs.
One localized fix
} catch (error) {
console.error('Failed to fetch models:', error);
setFetchError(t('providers.fetchModelsError'));
- setHasFetched(false);
- setFetchedCredentials(null);
+ setHasFetched(true);
+ setFetchedCredentials({ baseUrl, apiKey });
}This preserves manual retry via the button, while the credential-change effect still clears the gate when either field changes.
Also applies to: 532-538
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-add-panel.tsx`
around lines 227 - 231, The fetch loop re-triggers after an error because
hasFetched remains false; change the catch block in the provider model fetch
logic to setHasFetched(true) (and keep setFetchError and
setFetchedCredentials(null)) so a failed fetch stops the debounce auto-retry but
still allows manual retry via the existing button; ensure the effect that clears
the gate on credential edits still runs when credential fields change (the same
effect that calls setHasFetched(false) on input changes) so user edits re-enable
fetching.
| function SectionHeader({ | ||
| title, | ||
| description, | ||
| onEdit, | ||
| editLabel, | ||
| }: { | ||
| title: string; | ||
| description?: string; | ||
| onEdit: () => void; | ||
| editLabel: string; | ||
| }) { | ||
| return ( | ||
| <HStack | ||
| justify="between" | ||
| align={description ? 'start' : 'center'} | ||
| className="border-b px-5 py-4" | ||
| > | ||
| <Stack gap={1}> | ||
| <Text className="text-[15px] font-semibold tracking-[-0.01em]"> | ||
| {title} | ||
| </Text> | ||
| {description && ( | ||
| <Text className="text-muted-foreground text-[13px]"> | ||
| {description} | ||
| </Text> | ||
| )} | ||
| </Stack> | ||
| <button | ||
| type="button" | ||
| onClick={onEdit} | ||
| className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none" | ||
| > | ||
| <Pencil className="size-3.5" /> | ||
| {editLabel} | ||
| </button> | ||
| </HStack> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the file
FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
test -f "$FILE" && echo "Found: $FILE" || { echo "Missing: $FILE"; exit 1; }
# Print the SectionHeader definition region (around the cited lines)
nl -ba "$FILE" | sed -n '250,380p'
# Find and print where SectionHeader is used (including around the cited Line 544)
nl -ba "$FILE" | sed -n '500,650p'
# Quick structural search: all usages of SectionHeader in the file
rg -n "SectionHeader\s*<|<SectionHeader|function SectionHeader|const SectionHeader" "$FILE"Repository: tale-project/tale
Length of output: 217
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
test -f "$FILE"
echo "=== SectionHeader definition (approx 250-380) ==="
cat -n "$FILE" | sed -n '250,380p'
echo "=== Usages around reported Line 544 (approx 500-650) ==="
cat -n "$FILE" | sed -n '500,650p'
echo "=== All SectionHeader usages (full lines) ==="
rg -n "SectionHeader" "$FILE"Repository: tale-project/tale
Length of output: 10001
Add a children slot to SectionHeader.
SectionHeader’s props type omits children, but ApiKeySection passes a nested header action (<button>…</button>) inside <SectionHeader> (around lines 544-557). Add children?: React.ReactNode to SectionHeader’s props and render {children} in the header so the action actually appears.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 288 - 323, SectionHeader's props currently omit children so passed
nested content from ApiKeySection isn't rendered; add children?: React.ReactNode
to the SectionHeader props signature and include {children} in the returned JSX
(for example next to or instead of the existing edit button as appropriate) so
nested header actions passed into <SectionHeader> are displayed; update the
function parameter destructuring to accept children and ensure the component
still types onEdit, title, description, and editLabel as before.
| <button | ||
| type="button" | ||
| onClick={onEdit} | ||
| className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none" | ||
| > | ||
| <Pencil className="size-3.5" /> | ||
| {editLabel} | ||
| </button> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
# Sanity check: file exists
test -f "$FILE" || { echo "Missing $FILE"; exit 1; }
# Show the exact regions referenced by the review (315-322, 549-556, 1443-1468) with line numbers
sed -n '300,340p' "$FILE" | nl -ba | sed -n '1,120p'
echo "-----"
sed -n '520,585p' "$FILE" | nl -ba | sed -n '1,140p'
echo "-----"
sed -n '1410,1495p' "$FILE" | nl -ba | sed -n '1,140p'
# Find all focus-visible:outline-none occurrences in the file
echo "-----"
rg -n "focus-visible:outline-none" "$FILE" || true
# Find any "outline-none" usage in the file (broader than focus-visible)
echo "-----"
rg -n "outline-none" "$FILE" || trueRepository: tale-project/tale
Length of output: 105
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
test -f "$FILE" || { echo "Missing $FILE"; exit 1; }
echo "=== Region 315-322 ==="
awk 'NR>=315 && NR<=322 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo "=== Region 549-556 ==="
awk 'NR>=549 && NR<=556 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo "=== Region 1443-1468 (trimmed to first 80 lines) ==="
awk 'NR>=1443 && NR<=1525 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo "=== Occurrences: focus-visible:outline-none ==="
rg -n "focus-visible:outline-none" "$FILE" || true
echo "=== Occurrences: outline-none (broader) ==="
rg -n "outline-none" "$FILE" || trueRepository: tale-project/tale
Length of output: 7151
🌐 Web query:
Tailwind CSS v4 focus-visible:outline-none outline-hidden outline-none only removes outline style
💡 Result:
In Tailwind CSS v4, the outline-none and outline-hidden utilities serve distinct purposes, and the change from v3 is intentional to ensure better accessibility and semantic clarity [1][2]. 1. outline-none: This utility now performs exactly what its name implies: it sets outline-style: none [1][3]. It is intended for use cases where you are providing your own custom focus styles (e.g., using rings or shadows) and want to completely suppress the browser's default focus outline [4][2]. 2. outline-hidden: This utility is the recommended replacement for the behavior of outline-none found in Tailwind CSS v3 [1][2]. While it suppresses the visual outline in standard rendering, it maintains a visible outline when the user's system is in forced-colors mode (e.g., Windows High Contrast Mode) [4][5]. This is critical for accessibility, as it ensures that focus remains visible to users who rely on high-contrast settings [1][5]. Summary of differences: - Standard Rendering: Both outline-none and outline-hidden set outline-style: none [6][5]. - Forced-Colors Mode: outline-hidden preserves a visible outline for accessibility; outline-none does not [1][5]. If you are upgrading from v3, you should generally replace your existing outline-none classes with outline-hidden to maintain the accessibility behavior you previously had [5][2]. You should only use outline-none if you specifically intend to remove the outline entirely in all scenarios, including high-contrast modes [4][2].
Citations:
- 1: Replace
outline-nonewithoutline-hidden, add newoutline-nonetailwindlabs/tailwindcss#14926 - 2: https://tailwindcss.com/docs/upgrade-guide
- 3:
outline-hiddenonly applies transparent outline whenforced-colorsis active? tailwindlabs/tailwindcss#16926 - 4: https://tailwindcss.com/docs/outline-style
- 5: [bug]: v4 registry incomplete outline-none to outline-hidden migration (19 components) shadcn-ui/ui#10101
- 6: [v4] :focus-visible no longer overrides default outline for some reason tailwindlabs/tailwindcss#15152
Fix keyboard focus styling: remove focus-visible:outline-none from action buttons
- In
services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx, the edit/test/undo/remove action buttons combinefocus-visible:outline-ring+focus-visible:outline-2+focus-visible:outline-offset-2withfocus-visible:outline-none. In Tailwind v4,outline-nonesetsoutline-style: noneon focus-visible, which suppresses the intended focus outline for keyboard users. - Remove
focus-visible:outline-nonefrom the buttons at the referenced blocks (~315-322, ~549-556, ~1443-1468).
Proposed fix
- className="text-muted-foreground hover:text-foreground focus-visible:outline-ring ... focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none"
+ className="text-muted-foreground hover:text-foreground focus-visible:outline-ring ... focus-visible:outline-2 focus-visible:outline-offset-2"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| type="button" | |
| onClick={onEdit} | |
| className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none" | |
| > | |
| <Pencil className="size-3.5" /> | |
| {editLabel} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={onEdit} | |
| className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2" | |
| > | |
| <Pencil className="size-3.5" /> | |
| {editLabel} | |
| </button> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 315 - 322, The action buttons in ProviderDetailDrawer use
conflicting focus-visible classes that suppress the outline; remove the
`focus-visible:outline-none` token from the className strings for the
edit/test/undo/remove buttons so the `focus-visible:outline-ring`,
`focus-visible:outline-2` and `focus-visible:outline-offset-2` rules can take
effect — locate the buttons tied to handlers like onEdit, onTest (or test
action), onUndo and onRemove in the ProviderDetailDrawer component and delete
`focus-visible:outline-none` from their className values.
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Avoid persisting the model config before the secret write succeeds.
If saveConfig() succeeds and saveSecret.mutateAsync() fails, the model update is already committed but the API key change is lost. That leaves a half-applied save while the dialog stays open as if nothing landed. Please move both writes behind a single backend operation or add compensating rollback.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 873 - 889, The current flow calls saveConfig(...) before
saveSecret.mutateAsync(...), which can leave config persisted if the secret
write fails; change the logic so both writes are atomic: either call
saveSecret.mutateAsync(...) first and only call saveConfig({ models:
updatedModels }) if the secret write succeeds, or perform both operations via a
single backend endpoint that applies both changes transactionally; if you cannot
change the backend, implement a compensating rollback by catching errors from
saveSecret.mutateAsync and reverting the saved config (using saveConfig with the
previous models snapshot) inside the catch block; update relevant symbols:
saveConfig, saveSecret.mutateAsync, setSavingSecret, form, modelKeyAction,
orgSlug, providerName, and updatedModels.
| <HStack gap={3}> | ||
| <Input | ||
| label="Input cost (USD / 1M tokens)" | ||
| type="number" | ||
| value={form.inputCostPerMillion} | ||
| onChange={(e) => | ||
| setForm((f) => ({ | ||
| ...f, | ||
| inputCostPerMillion: e.target.value, | ||
| })) | ||
| } | ||
| placeholder="e.g., 2.50" | ||
| min={0} | ||
| step={0.01} | ||
| /> | ||
| <Input | ||
| label="Output cost (USD / 1M tokens)" | ||
| type="number" | ||
| value={form.outputCostPerMillion} | ||
| onChange={(e) => | ||
| setForm((f) => ({ | ||
| ...f, | ||
| outputCostPerMillion: e.target.value, | ||
| })) | ||
| } | ||
| placeholder="e.g., 10.00" | ||
| min={0} | ||
| step={0.01} | ||
| /> | ||
| </HStack> | ||
| {form.tags.includes('image-generation') && ( | ||
| <Input | ||
| label="Cost per image (USD)" | ||
| type="number" | ||
| value={form.imageCostPerImage} | ||
| onChange={(e) => | ||
| setForm((f) => ({ | ||
| ...f, | ||
| imageCostPerImage: e.target.value, | ||
| })) | ||
| } | ||
| placeholder="e.g., 0.06" | ||
| min={0} | ||
| step={0.01} | ||
| /> |
There was a problem hiding this comment.
Translate the new cost field copy.
These labels and placeholders bypass useT, so the new drawer will show mixed-language controls in non-English locales.
As per coding guidelines, “Every user-facing string goes through the translation layer via useT(namespace) hook … No hardcoded user-facing strings; always use the translation hook.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 1374 - 1418, The new cost input labels/placeholders are hardcoded;
update provider-detail-drawer.tsx to use the translation hook (useT) for all
user-facing strings in the Input components for inputCostPerMillion,
outputCostPerMillion, and imageCostPerImage (labels and placeholders), and any
conditional text for the image-generation branch; replace the literal strings
("Input cost (USD / 1M tokens)", "Output cost (USD / 1M tokens)", "Cost per
image (USD)", "e.g., 2.50", "e.g., 10.00", "e.g., 0.06") with t('your.key')
calls from useT(namespace) so the HStack/Input controls render translated text
across locales while keeping existing props (value, onChange, min, step) intact.
| const secretsPath = resolveProviderSecretsPath( | ||
| args.orgSlug, | ||
| args.providerName, | ||
| ); | ||
| const secrets = parseProviderSecrets(await decryptSecretsFile(secretsPath)); | ||
| const apiKey = secrets.apiKey; |
There was a problem hiding this comment.
Normalize configured-secret read failures to PROVIDER_FETCH_FAILED.
Line 1167 can throw raw decrypt/parse errors, which breaks stable client error handling and may surface low-level filesystem/decryption messages instead of action-level error codes.
Proposed fix
- const secrets = parseProviderSecrets(await decryptSecretsFile(secretsPath));
- const apiKey = secrets.apiKey;
+ let apiKey: string | undefined;
+ try {
+ const secrets = parseProviderSecrets(await decryptSecretsFile(secretsPath));
+ apiKey = secrets.apiKey;
+ } catch (err) {
+ throw new ConvexError({
+ code: 'PROVIDER_FETCH_FAILED',
+ message: `Cannot read provider credentials for "${args.providerName}"`,
+ });
+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/convex/providers/file_actions.ts` around lines 1163 - 1168,
Wrap the decrypt/parse sequence that uses resolveProviderSecretsPath,
decryptSecretsFile and parseProviderSecrets in a try/catch and normalize any
thrown errors into a provider-level error with the PROVIDER_FETCH_FAILED code;
specifically, catch errors from decryptSecretsFile(...) and
parseProviderSecrets(...), then throw a new error (or return the expected error
object) that sets/uses PROVIDER_FETCH_FAILED as the error code/message and
attach the original error as a cause or inner detail for diagnostics so
low-level decrypt/parse failures do not leak raw errors to callers.
| "tagTranscription": "Transkription", | ||
| "details": "Anbieterdetails", | ||
| "capabilities": "Fähigkeiten", | ||
| "apiKeyUpdated": "Ihr API-Schlüssel wurde erfolgreich gespeichert." |
There was a problem hiding this comment.
Use informal German pronoun in success toast copy.
"Ihr API-Schlüssel wurde erfolgreich gespeichert." uses formal register. This locale is using informal address elsewhere; switch to a dein form for consistency (for example: "Dein API-Schlüssel wurde erfolgreich gespeichert.").
As per coding guidelines: "Use sentence case in every translation; use informal form across all languages (du in German, tu in French, never Sie or vous)".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/messages/de.json` at line 1158, Update the translation
value for the key "apiKeyUpdated" to use the informal German pronoun and
sentence case; replace the current string "Ihr API-Schlüssel wurde erfolgreich
gespeichert." with "Dein API-Schlüssel wurde erfolgreich gespeichert." so it
matches the project's informal register (du) used elsewhere.
| "tagTranscription": "Transcription", | ||
| "details": "Détails du fournisseur", | ||
| "capabilities": "Capacités", | ||
| "apiKeyUpdated": "Votre clé API a été enregistrée avec succès." |
There was a problem hiding this comment.
Use informal French register in success toast
Line 1158 uses formal register (“Votre”), but this locale requires informal form.
Suggested fix
- "apiKeyUpdated": "Votre clé API a été enregistrée avec succès."
+ "apiKeyUpdated": "Ta clé API a été enregistrée avec succès."As per coding guidelines: "Use sentence case in every translation; use informal form across all languages (du in German, tu in French, never Sie or vous)".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "apiKeyUpdated": "Votre clé API a été enregistrée avec succès." | |
| "apiKeyUpdated": "Ta clé API a été enregistrée avec succès." |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/messages/fr.json` at line 1158, Update the French
translation value for the key "apiKeyUpdated" to use informal register and
sentence case: replace the formal "Votre" with the informal "Ta" so the message
reads "Ta clé API a été enregistrée avec succès." Ensure you only modify the
string value for the "apiKeyUpdated" key.
- Drop unused `displayNameFromId` helper and `fields` destructure in provider-add-panel. - Replace `!` non-null assertions with locally-bound `formIndex` so the `!= null` narrowing carries into the IconButton onClick closures. - Drop redundant `as number` assertions on the drawer's row.configuredIndex. - Use `undefined` early-return in the auto-fetch debounce effect so it satisfies oxlint's consistent-return. - Let SectionHeader accept children so the API-key section can render its "Test connection" pill next to the edit button. - Remove 7 orphan i18n keys left behind when the 1.4k-line provider route was replaced by the drawer (backToProviders, costPerMillionTokens, modelApiKeyOverrideIndicator, modelOverrideIndicator, modelsSelected, providerOptions.indicator, selectAllModels) across de/en/fr.
These files were modified as part of local development and should not have been committed. Restoring to main's content so the PR scope stays on the provider drawer.
- api-keys table: render `… {suffix}` when the row has a suffix but no
start/prefix (e.g. an SDK return that omits the prefix), instead of
collapsing to `-`.
- provider-add-panel: on a failed model fetch, set `hasFetched=true`
and snapshot the failing credentials so the debounced auto-fetch
effect stops re-firing every render. The cleanup effect re-arms on
the next credential change, and the manual Fetch button stays
available for an immediate retry.
- provider-detail-drawer: drop `focus-visible:outline-none` from the
edit/test/undo/remove buttons — it was suppressing the visible focus
ring set by the `outline-ring`/`outline-2`/`outline-offset-2`
classes immediately before it.
- provider-detail-drawer: save the per-model API key BEFORE the
config update. If the secret write fails we now bail out without
leaving an orphaned config row that points at a key the encrypted
file doesn't have.
- provider-detail-drawer: i18n the new cost input labels and
placeholders (input cost, output cost, image cost) across de/en/fr.
- file_actions: wrap `decryptSecretsFile` + `parseProviderSecrets` in
`fetchConfiguredProviderModels` with a try/catch that normalises any
thrown error into a `PROVIDER_FETCH_FAILED` ConvexError, matching
the surrounding action's error contract.
Summary
provider-detail-drawercomponent; the route now just mounts it.Showing N of M modelscounter.start … suffix(6 + 4 chars) so admins can disambiguate keys with the same vendor prefix and cross-check against the vendor dashboard. Adds a newfetchConfiguredProviderModelsConvex action that uses the stored secret./api-key/create, persist them in a new nullablesuffixcolumn on theapikeytable, and render keys in the dashboard asstart … suffix. Pre-existing rows fall back to prefix-only.examples/providers/openai.jsonand shrink the bundled OpenRouter example.Test plan
start … suffix; legacy keys still render with prefix only.bun run checkis green locally.Summary by CodeRabbit
Release Notes
New Features
Improvements