From e62cc5208316f18a0b1b0ec3c1e7fd02efed1214 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Sun, 7 Jun 2026 18:23:30 +0530 Subject: [PATCH] feat(playground): filter model picker to providers with active credentials The playground's ModelPicker reads from a static catalog (web/src/lib/models.ts) and never looked at the credentials configured in /workspace/credentials. So a user with only Anthropic configured still sees OpenAI / Gemini / Mistral / DeepSeek / Groq in the dropdown, and picking any of those is a guaranteed dispatch failure with a "no credential resolved" error. Surface the configured set up front: - web/src/app/playground/page.tsx: server-fetch /v1/llm-credentials?workspace_id=... in parallel with the prompts + sessions queries; project to a sorted set of distinct providers whose revoked_at is NULL; pass that into PlaygroundComposer. - PlaygroundComposer + ModelCard: thread the configuredProviders array through to both ModelPicker instances (single + compare). - ModelPicker: new optional `availableProviders?: string[]` prop. - undefined -> show all providers (legacy behavior; judges and elsewhere keep working unchanged) - non-empty -> filter s to that set - empty -> only the Custom escape hatch + a "No LLM credentials configured. Add one ->" link to /workspace/credentials The Custom escape hatch always stays available so a model the curated catalog hasn't picked up yet (e.g. a new Claude/Gemini preview) is one free-text input away. Verified locally: - pnpm typecheck: clean - pnpm lint: clean - pnpm build: succeeds Signed-off-by: Gaurav Dubey Signed-off-by: gaurav0107 --- web/src/app/playground/page.tsx | 23 ++++++++++++++++- web/src/components/ModelPicker.tsx | 34 ++++++++++++++++++++++++- web/src/components/PlaygroundClient.tsx | 22 +++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/web/src/app/playground/page.tsx b/web/src/app/playground/page.tsx index a5a9828..e060094 100644 --- a/web/src/app/playground/page.tsx +++ b/web/src/app/playground/page.tsx @@ -45,6 +45,11 @@ interface PlaygroundSessionList { items: PlaygroundSessionOut[]; } +interface LLMCredentialRow { + provider: string; + revoked_at: string | null; +} + export default async function PlaygroundPage() { const { active, all, reason } = await resolveActiveProject(); if (!active) { @@ -61,17 +66,32 @@ export default async function PlaygroundPage() { ); } - const [promptsRes, sessionsRes] = await Promise.all([ + const [promptsRes, sessionsRes, credentialsRes] = await Promise.all([ apiGet( `/v1/prompts?project_id=${encodeURIComponent(active.id)}`, ), apiGet( `/v1/playground/runs?project_id=${encodeURIComponent(active.id)}&limit=20`, ), + apiGet( + `/v1/llm-credentials?workspace_id=${encodeURIComponent(active.workspace_id)}`, + ), ]); const prompts = promptsRes.data ?? []; const sessions = sessionsRes.data?.items ?? []; + // Active credentials (revoked_at IS NULL) -> distinct provider list. + // The playground filters its model picker by this so users see only + // providers they actually have keys for. An admin/root user can see + // empty configured-providers (and pick anything via "Custom..."); a + // member without credentials sees the empty-state nudge to settings. + const configuredProviders = Array.from( + new Set( + (credentialsRes.data ?? []) + .filter((c) => c.revoked_at === null) + .map((c) => c.provider), + ), + ).sort(); const promptOptions = await Promise.all( prompts.map(async (p): Promise => { @@ -109,6 +129,7 @@ export default async function PlaygroundPage() { diff --git a/web/src/components/ModelPicker.tsx b/web/src/components/ModelPicker.tsx index 84d38d5..6c71266 100644 --- a/web/src/components/ModelPicker.tsx +++ b/web/src/components/ModelPicker.tsx @@ -31,12 +31,25 @@ export function ModelPicker({ onChange, label, ariaLabel, + availableProviders, }: { value: string; onChange: (next: string) => void; /** Optional label for the field wrapper. If omitted, render just the inputs. */ label?: string; ariaLabel?: string; + /** + * Filter the picker to providers the workspace has credentials for. + * Pass the result of /v1/llm-credentials projected to distinct + * provider names. Omit (undefined) for callers without workspace + * context — judges and others — which keeps today's "show + * everything" behavior. + * + * - undefined: show all providers (legacy) + * - non-empty array: show only those providers + Custom escape hatch + * - empty array: show only Custom + nudge to /workspace/credentials + */ + availableProviders?: string[]; }) { // If the current value isn't in the catalog, expose a free-text mode // so the user can edit it directly without losing it. @@ -44,6 +57,14 @@ export function ModelPicker({ const initialCustom = value !== "" && !inCatalog; const [customMode, setCustomMode] = useState(initialCustom); + const visibleProviders = + availableProviders === undefined + ? PROVIDERS + : PROVIDERS.filter((p) => availableProviders.includes(p.value)); + + const noCredentials = + availableProviders !== undefined && availableProviders.length === 0; + const select = (