Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion web/src/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -61,17 +66,32 @@ export default async function PlaygroundPage() {
);
}

const [promptsRes, sessionsRes] = await Promise.all([
const [promptsRes, sessionsRes, credentialsRes] = await Promise.all([
apiGet<PromptRow[]>(
`/v1/prompts?project_id=${encodeURIComponent(active.id)}`,
),
apiGet<PlaygroundSessionList>(
`/v1/playground/runs?project_id=${encodeURIComponent(active.id)}&limit=20`,
),
apiGet<LLMCredentialRow[]>(
`/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<PromptOption> => {
Expand Down Expand Up @@ -109,6 +129,7 @@ export default async function PlaygroundPage() {
<PlaygroundComposer
projectId={active.id}
prompts={promptOptions}
configuredProviders={configuredProviders}
/>
<RecentSessionsCard sessions={sessions} />
</div>
Expand Down
34 changes: 33 additions & 1 deletion web/src/components/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,40 @@ 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.
const inCatalog = ALL_MODELS.some((m) => m.value === value);
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 = (
<select
aria-label={ariaLabel ?? "model"}
Expand All @@ -61,7 +82,7 @@ export function ModelPicker({
}}
style={{ width: "100%" }}
>
{PROVIDERS.map((p) => (
{visibleProviders.map((p) => (
<optgroup key={p.value} label={p.label}>
{MODEL_CATALOG[p.value].map((m) => (
<option key={m.value} value={m.value}>
Expand Down Expand Up @@ -107,6 +128,15 @@ export function ModelPicker({
</span>
) : null;

const noCredsNudge = noCredentials ? (
<span style={{ fontSize: 11, color: "var(--text-3)", marginTop: 4 }}>
No LLM credentials configured.{" "}
<a href="/workspace/credentials" style={{ color: "var(--link)" }}>
Add one →
</a>
</span>
) : null;

if (label) {
return (
<label style={{ display: "grid", gap: 4 }}>
Expand All @@ -123,6 +153,7 @@ export function ModelPicker({
{select}
{customInput}
{meta}
{noCredsNudge}
</label>
);
}
Expand All @@ -132,6 +163,7 @@ export function ModelPicker({
{select}
{customInput}
{meta}
{noCredsNudge}
</div>
);
}
Expand Down
22 changes: 21 additions & 1 deletion web/src/components/PlaygroundClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,20 @@ function extractVariables(template: string): string[] {
export function PlaygroundComposer({
projectId,
prompts,
configuredProviders,
}: {
projectId: string;
prompts: PromptOption[];
/**
* Providers the workspace has at least one active LLM credential for,
* resolved server-side from /v1/llm-credentials. The model picker
* filters its catalog to only these providers — picking a model
* without a credential is a guaranteed dispatch failure, so we
* surface the configured set up front rather than letting the user
* pick something that fails. Empty array = no creds; the picker
* shows the empty-state nudge to /workspace/credentials.
*/
configuredProviders: string[];
}) {
const [mode, setMode] = useState<"single" | "compare">("single");
const [promptId, setPromptId] = useState<string>("");
Expand Down Expand Up @@ -212,6 +223,7 @@ export function PlaygroundComposer({
setTemperature={setTemperature}
maxTokens={maxTokens}
setMaxTokens={setMaxTokens}
configuredProviders={configuredProviders}
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
{error ? (
Expand Down Expand Up @@ -471,6 +483,7 @@ function ModelCard({
setTemperature,
maxTokens,
setMaxTokens,
configuredProviders,
}: {
mode: "single" | "compare";
model: string;
Expand All @@ -481,6 +494,7 @@ function ModelCard({
setTemperature: (s: string) => void;
maxTokens: string;
setMaxTokens: (s: string) => void;
configuredProviders: string[];
}) {
return (
<section className="card card-pad-lg">
Expand All @@ -496,9 +510,15 @@ function ModelCard({
label={mode === "compare" ? "Model A" : "Model"}
value={model}
onChange={setModel}
availableProviders={configuredProviders}
/>
{mode === "compare" ? (
<ModelPicker label="Model B" value={modelB} onChange={setModelB} />
<ModelPicker
label="Model B"
value={modelB}
onChange={setModelB}
availableProviders={configuredProviders}
/>
) : null}
<Field label="Temperature" hint="0.0..2.0 (blank = provider default)">
<input
Expand Down
Loading