fix(desktop): disambiguate provider API key labels and annotate mint key (#4406)

Two different credentials were presented under the same name throughout
the app. The top-level credential field for non-Anthropic providers
(OpenAI, OpenAI-compatible, OpenRouter) was labeled "OpenAI API Key" via
a hardcoded binary ternary repeated in three dialogs. The card-minting
key (`OPENAI_API_KEY`) and the runtime credential
(`OPENAI_COMPAT_API_KEY`) have independent endpoint namespaces and
consumers (`OPENAI_COMPAT_BASE_URL`/`OPENAI_COMPAT_API_KEY` for runtime,
`OPENAI_BASE_URL`/`OPENAI_API_KEY` for minting) and must remain separate
— either may require a different credential. This PR makes them
impossible to confuse in the UI.

## Changes

**Provider-accurate labels from the credential table.**
`PROVIDER_CREDENTIAL_CONFIG` entries now carry an `apiKeyLabel` paired
with `secretEnvVar` as a discriminated union (both present or neither —
a future provider cannot ship a secret field with no label).
`getProviderApiKeyLabel(providerId)` is the single source of truth. The
three hardcoded ternaries in `AgentConfigFields`,
`AgentInstanceEditDialog`, and `AgentDefinitionDialog` are replaced by
this helper. Labels: `openai` → "OpenAI Runtime API Key",
`openai-compat` → "OpenAI-compatible Runtime API Key", `openrouter` →
"OpenRouter API Key" (was incorrectly "OpenAI API Key"), `anthropic` →
"Anthropic API Key" (unchanged).

**Field names its backing env var.** `PersonaProviderApiKeyField`
renders the env var name as a monospace hint beneath the label with
`aria-describedby` wiring. All three call sites pass their
`secretEnvVar`. A user who sees `OPENAI_API_KEY` in the mint dialog can
now confirm at a glance that the credential field shows
`OPENAI_COMPAT_API_KEY` — a different key.

**Signpost visible at the decision point.** `CARD_MINT_KEY_ANNOTATIONS`
is exported from `agentConfigOptions.tsx` (single source) and passed as
`keyAnnotations` to all three generic env editors: both `EnvVarsEditor`
branches in Agent Defaults, `EditAgentAdvancedFields`, and
`PersonaAdvancedFields`. `CardMintKeyCue` — a new small component —
renders an always-visible muted cue beneath the Advanced toggle when
`OPENAI_API_KEY` is present in global env (Advanced is collapsed by
default, so the per-row annotation is invisible until the cue guides the
user to open it).

**Model discovery error copy.** The `OPENAI_COMPAT_API_KEY required`
message now reads "Enter an OpenAI runtime API key
(OPENAI_COMPAT_API_KEY) to load OpenAI models." — naming the env var
explicitly so it cannot be confused with the mint key.

## Tests

- `getProviderApiKeyLabel` helper: pinned correct label per provider
including the new distinct labels for `openai` and `openai-compat`
- `PersonaProviderApiKeyField` render: semantic label present; env-var
hint rendered when `envVarName` provided; `aria-describedby` wired to
hint id; hint and describedby absent when prop omitted
- `EnvVarsEditor` render: annotation appears exactly once on the
matching row; absent for non-matching rows
- `personaModelDiscoveryStatus`: pinned new copy naming
`OPENAI_COMPAT_API_KEY` explicitly
- Playwright: stale `"OpenAI API Key"` selectors updated; new
`card-mint-key-cue-visible-and-annotation-in-advanced` test covers
Will's exact path (databricks_v2 global provider + saved
`OPENAI_API_KEY` → cue visible before opening Advanced → annotation
present after opening)

## File sizes (post-format)

| File | Lines |
|------|-------|
| `AgentConfigFields.tsx` | 994 (≤ 996) |
| `AgentInstanceEditDialog.tsx` | 1228 (≤ 1228) |
| `AgentDefinitionDialog.tsx` | 1045 (≤ 1047) |

Related: [#4140](https://github.com/block/buzz/pull/4140)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg <9a232143a0336e34814a6a0f4947e11bc50bc641f21572c886fd0ae1f7a4e867@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-03 11:12:34 -04:00
committed by GitHub
co-authored by npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg
parent f810a2f49e
commit 5e0efb0bb9
16 changed files with 505 additions and 39 deletions
@@ -32,9 +32,11 @@ import {
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CARD_MINT_KEY_ANNOTATIONS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
getProviderApiKeyEnvVar,
getProviderApiKeyLabel,
runtimeSupportsLlmProviderSelection,
} from "@/features/agents/ui/agentConfigOptions";
import {
@@ -54,6 +56,7 @@ import {
} from "@/features/agents/ui/buzzAgentModelTuningFields";
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { CardMintKeyCue } from "./CardMintKeyCue";
import { getGlobalAgentCredentialState } from "./globalAgentCredentialState";
export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
@@ -74,7 +77,6 @@ const PROGRESSIVE_FIELDS_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;
type AgentConfigDisclosure =
| "full"
| "onboarding-essential"
@@ -85,13 +87,9 @@ type AgentConfigDisclosure =
// - auto-select a valid model when the provider changes
// - keep the model select usable during discovery
// - preserve credential env vars across provider switches (the abandoned
// provider's key stays in env_vars — visible/deletable under Advanced —
// so flipping back never loses a typed key; spawned agents may therefore
// see credentials for providers they don't use)
// provider's key stays in env_vars — visible/deletable under Advanced)
// - require a provider before model/effort are editable (no saveable
// invalid state — design principle #4). Note: legacy configs saved with
// a model but no provider are cleared by the pre-existing orphan-model
// effect on next edit — deliberate data healing, documented in PR.
// invalid state — design principle #4)
const autoSelectModelOnProviderChange = true;
const disableModelSelectDuringDiscovery = false;
const preserveCredentialEnvVarsOnProviderChange = true;
@@ -747,6 +745,7 @@ export function AgentConfigFields({
<div className={blockClassName}>
<PersonaProviderApiKeyField
disabled={false}
envVarName={apiKeyEnvVar}
inheritedLabel={
apiKeyFileSatisfied
? "Set in runtime config"
@@ -754,11 +753,7 @@ export function AgentConfigFields({
}
isInherited={apiKeyInherited}
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
onValueChange={(value) =>
onConfigChange({
...config,
@@ -869,6 +864,7 @@ export function AgentConfigFields({
{showAdvancedFields ? (
<div className={cn(blockClassName, "space-y-3")}>
<CardMintKeyCue envVars={config.env_vars} />
<button
aria-expanded={advancedOpen}
className={cn(
@@ -912,6 +908,7 @@ export function AgentConfigFields({
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
@@ -930,6 +927,7 @@ export function AgentConfigFields({
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
@@ -42,6 +42,7 @@ import {
getDefaultPersonaRuntime,
getPersonaModelOptions,
getPersonaProviderOptions,
getProviderApiKeyLabel,
getRuntimePersonaModelOptions,
NO_RUNTIME_DROPDOWN_VALUE,
runtimeSupportsLlmProviderSelection,
@@ -907,14 +908,11 @@ export function AgentDefinitionDialog({
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={
effectiveProvider === "anthropic"
? "Anthropic API key"
: "OpenAI API key"
}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
@@ -75,7 +75,10 @@ import {
getBakedModelInheritLabel,
getBakedProviderInheritLabel,
} from "./bakedEnvHelpers";
import { getProviderApiKeyEnvVar } from "./agentConfigOptions";
import {
getProviderApiKeyEnvVar,
getProviderApiKeyLabel,
} from "./agentConfigOptions";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
@@ -1061,14 +1064,11 @@ export function AgentInstanceEditDialog({
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={updateMutation.isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
@@ -0,0 +1,29 @@
/**
* Always-visible cue shown in Agent Defaults when a global `OPENAI_API_KEY`
* row exists (nonblank). The Advanced section is collapsed by default, so
* the `keyAnnotations` hint on that row is invisible until the user opens it
* — this cue bridges the gap by surfacing the information at the decision
* point.
*
* Renders nothing when `OPENAI_API_KEY` is absent or blank.
*/
export function CardMintKeyCue({
envVars,
}: {
envVars: Record<string, string>;
}) {
const isSet =
"OPENAI_API_KEY" in envVars &&
(envVars.OPENAI_API_KEY ?? "").trim().length > 0;
if (!isSet) return null;
return (
<p
className="text-xs text-muted-foreground"
data-testid="card-mint-key-cue"
>
Card-minting key <span className="font-mono">OPENAI_API_KEY</span> is set
under Advanced → Environment variables.
</p>
);
}
@@ -3,6 +3,7 @@ import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import {
CARD_MINT_KEY_ANNOTATIONS,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
@@ -247,6 +248,7 @@ export function EditAgentAdvancedFields({
helperText="Per-agent env vars. Override the template's vars on collision."
inheritedFrom={inheritedEnvVars}
inheritedLabel="template / global defaults"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
onChange={onEnvVarsChange}
requiredKeys={requiredEnvKeys}
value={envVars}
@@ -523,3 +523,97 @@ test("getBakedProviderInheritLabel_empty_options_falls_back_to_raw_id", () => {
"empty options table must fall back to raw id",
);
});
// ── keyAnnotations — annotation lookup invariants ─────────────────────────────
//
// `keyAnnotations` is a pass-through prop: the renderer does `keyAnnotations?.[key]`.
// The invariant worth pinning is that the prop contract is respected at the
// data level — an annotation for one key does NOT bleed into another key.
// (Rendering itself is trivially conditional; no logic to extract.)
test("keyAnnotations_present_key_has_annotation", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
assert.equal(
annotations.OPENAI_API_KEY,
"Used for minting agent trading cards",
);
});
test("keyAnnotations_absent_key_is_undefined", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
assert.equal(annotations.ANTHROPIC_API_KEY, undefined);
});
test("keyAnnotations_empty_map_has_no_annotations", () => {
const annotations = {};
assert.equal(annotations.OPENAI_API_KEY, undefined);
});
test("keyAnnotations_only_matching_key_gets_annotation", () => {
// Verifies the per-key lookup is not accidentally global.
const annotations = { OPENAI_API_KEY: "card minting" };
const keys = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "FOO"];
const results = keys.map((k) => annotations[k] ?? null);
assert.deepEqual(results, ["card minting", null, null]);
});
// ── keyAnnotations render — annotation appears only on matching row ─────────
//
// renderToStaticMarkup exercises the real JSX path:
// {keyAnnotations?.[row.key] ? <p ...>{annotation}</p> : null}
// This confirms the prop is plumbed through to the DOM correctly and that
// annotation text is scoped to its matching row.
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { EnvVarsEditor } from "./EnvVarsEditor.tsx";
test("keyAnnotations_annotation_present_only_on_matching_row", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
const html = renderToStaticMarkup(
React.createElement(EnvVarsEditor, {
disabled: false,
fileSatisfiedKeys: [],
hiddenKeys: [],
keyAnnotations: annotations,
onChange: () => {},
requiredKeys: [],
value: { OPENAI_API_KEY: "sk-placeholder", ANTHROPIC_API_KEY: "sk-ant" },
}),
);
assert.ok(
html.includes("Used for minting agent trading cards"),
"annotation must appear in rendered output for OPENAI_API_KEY row",
);
// The annotation must not bleed to other rows — check that it appears only once.
const count = (html.match(/Used for minting agent trading cards/g) ?? [])
.length;
assert.equal(count, 1, "annotation must appear exactly once");
});
test("keyAnnotations_annotation_absent_for_non_matching_rows", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
const html = renderToStaticMarkup(
React.createElement(EnvVarsEditor, {
disabled: false,
fileSatisfiedKeys: [],
hiddenKeys: [],
keyAnnotations: annotations,
onChange: () => {},
requiredKeys: [],
value: { ANTHROPIC_API_KEY: "sk-ant", MY_VAR: "foo" },
}),
);
assert.ok(
!html.includes("Used for minting agent trading cards"),
"annotation must not appear when its key is not in the env map",
);
});
@@ -165,6 +165,14 @@ type EnvVarsEditorProps = {
inheritedRows?: readonly InheritedEnvRow[];
/** Label for the inherited-row tag (e.g. "build"). Defaults to "build". */
inheritedRowsLabel?: string;
/**
* Optional muted one-line annotation for specific env var keys. Rendered
* below any row whose key appears in this map — required rows, user rows,
* and user rows whose key is typed mid-edit. Intended for contextual hints
* like `{ OPENAI_API_KEY: "Used for minting agent trading cards" }` that
* help users distinguish two keys with similar names.
*/
keyAnnotations?: Readonly<Record<string, string>>;
};
type Row = { id: string; key: string; value: string };
@@ -191,6 +199,7 @@ export function EnvVarsEditor({
focusKey,
inheritedRows = [],
inheritedRowsLabel = "build",
keyAnnotations,
}: EnvVarsEditorProps) {
// Keys that render as their own special rows (required amber rows or
// file-satisfied read-only rows). These must NEVER enter `rows` state —
@@ -406,6 +415,14 @@ export function EnvVarsEditor({
</p>
);
})()}
{keyAnnotations?.[key] ? (
<p
className="ml-1 text-xs text-muted-foreground"
data-testid="env-vars-key-annotation"
>
{keyAnnotations[key]}
</p>
) : null}
</div>
);
})}
@@ -596,6 +613,14 @@ export function EnvVarsEditor({
</p>
);
})()}
{row.key.length > 0 && keyAnnotations?.[row.key] ? (
<p
className="ml-1 text-xs text-muted-foreground"
data-testid="env-vars-key-annotation"
>
{keyAnnotations[row.key]}
</p>
) : null}
</div>
);
})}
@@ -6,6 +6,7 @@ import type { PersonaBehaviorDraft } from "./personaBehaviorDraft";
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields";
import {
CARD_MINT_KEY_ANNOTATIONS,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
@@ -142,6 +143,7 @@ export function PersonaAdvancedFields({
disabled={disabled}
fileSatisfiedKeys={fileSatisfiedEnvKeys}
hiddenKeys={hiddenEnvKeys}
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
onChange={onEnvVarsChange}
requiredKeys={requiredEnvKeys}
value={envVars}
@@ -0,0 +1,170 @@
/**
* Behavioral tests for PersonaProviderApiKeyField.
*
* Tests the rendering invariants that matter for the disambiguation story:
* - semantic label is present in the rendered output
* - envVarName hint is rendered when the prop is present
* - hint id is wired to the input via aria-describedby
* - hint is absent when envVarName is omitted
* - two simultaneous instances produce unique IDs (no duplicate-ID collision
* in the nested AgentInstanceEditDialog + AgentDefaultsDialog path)
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField.tsx";
function makeProps(overrides = {}) {
return {
disabled: false,
isInherited: false,
inheritedLabel: "Set in global defaults",
isRequired: false,
label: "OpenAI Runtime API Key",
onValueChange: () => {},
value: "",
...overrides,
};
}
/** Extract the value of the first attribute matching `name="..."` in html. */
function extractAttr(html, attrName) {
const re = new RegExp(`${attrName}="([^"]+)"`);
const m = re.exec(html);
return m ? m[1] : null;
}
/** Extract ALL values of an attribute from html, in document order. */
function extractAllAttrs(html, attrName) {
const re = new RegExp(`${attrName}="([^"]+)"`, "g");
return Array.from(html.matchAll(re), (m) => m[1]);
}
test("PersonaProviderApiKeyField_renders_semantic_label", () => {
const html = renderToStaticMarkup(
React.createElement(PersonaProviderApiKeyField, makeProps()),
);
assert.ok(
html.includes("OpenAI Runtime API Key"),
"semantic label must appear in rendered output",
);
});
test("PersonaProviderApiKeyField_renders_env_var_hint_when_envVarName_present", () => {
const html = renderToStaticMarkup(
React.createElement(
PersonaProviderApiKeyField,
makeProps({ envVarName: "OPENAI_COMPAT_API_KEY" }),
),
);
assert.ok(
html.includes("OPENAI_COMPAT_API_KEY"),
"env-var hint must appear when envVarName is provided",
);
});
test("PersonaProviderApiKeyField_wires_hint_id_via_aria_describedby", () => {
const html = renderToStaticMarkup(
React.createElement(
PersonaProviderApiKeyField,
makeProps({ envVarName: "OPENAI_COMPAT_API_KEY" }),
),
);
// Extract the dynamically-generated hint id from the rendered paragraph.
const hintId = extractAttr(html, "id");
assert.ok(hintId, "hint paragraph must have an id");
assert.ok(
hintId.startsWith("persona-provider-api-key-hint-"),
`hint id must follow the expected prefix, got: ${hintId}`,
);
// The input's aria-describedby must point at the same id.
const describedBy = extractAttr(html, "aria-describedby");
assert.equal(
describedBy,
hintId,
"input aria-describedby must reference the hint's id",
);
});
test("PersonaProviderApiKeyField_omits_hint_when_envVarName_absent", () => {
const html = renderToStaticMarkup(
React.createElement(PersonaProviderApiKeyField, makeProps()),
);
assert.ok(
!html.includes("aria-describedby"),
"no aria-describedby when envVarName is omitted",
);
assert.ok(
!html.includes("persona-provider-api-key-hint"),
"hint id must not appear when envVarName is omitted",
);
});
test("PersonaProviderApiKeyField_two_instances_have_unique_ids_and_each_aria_describedby_resolves_to_own_hint", () => {
// Render BOTH instances in a single renderToStaticMarkup call — this
// mirrors the real nested-dialog DOM where AgentInstanceEditDialog's
// credential field and the nested AgentDefaultsDialog's field are
// simultaneously mounted under the same React root. A shared root is what
// makes React.useId() guarantee uniqueness; two separate renderToStaticMarkup
// calls each reset the counter and would produce the same ID.
const combined = renderToStaticMarkup(
React.createElement(
React.Fragment,
null,
React.createElement(
PersonaProviderApiKeyField,
makeProps({
label: "Anthropic API Key",
envVarName: "ANTHROPIC_API_KEY",
}),
),
React.createElement(
PersonaProviderApiKeyField,
makeProps({
label: "OpenAI Runtime API Key",
envVarName: "OPENAI_COMPAT_API_KEY",
}),
),
),
);
// Two hint paragraph ids must be present and distinct.
const allHintIds = extractAllAttrs(combined, "id").filter((id) =>
id.startsWith("persona-provider-api-key-hint-"),
);
assert.equal(allHintIds.length, 2, "exactly two hint ids must be present");
const [hintIdA, hintIdB] = allHintIds;
assert.notEqual(hintIdA, hintIdB, "two instances must not share a hint id");
// Each input's aria-describedby must match its own hint id (same order).
const allDescribedBy = extractAllAttrs(combined, "aria-describedby");
assert.equal(
allDescribedBy.length,
2,
"exactly two aria-describedby attributes must be present",
);
assert.equal(
allDescribedBy[0],
hintIdA,
"instance A: aria-describedby must reference instance A's own hint",
);
assert.equal(
allDescribedBy[1],
hintIdB,
"instance B: aria-describedby must reference instance B's own hint",
);
// Confirm each instance names its own env var in the rendered output.
assert.ok(
combined.includes("ANTHROPIC_API_KEY"),
"combined output must name ANTHROPIC_API_KEY",
);
assert.ok(
combined.includes("OPENAI_COMPAT_API_KEY"),
"combined output must name OPENAI_COMPAT_API_KEY",
);
});
@@ -25,6 +25,7 @@ import {
*/
export function PersonaProviderApiKeyField({
disabled,
envVarName,
isInherited,
inheritedLabel,
isRequired,
@@ -33,6 +34,13 @@ export function PersonaProviderApiKeyField({
value,
}: {
disabled: boolean;
/**
* The backing environment variable name, e.g. `OPENAI_COMPAT_API_KEY`.
* Rendered as a monospace hint beneath the label so users can distinguish
* this field from other keys with similar names (e.g. `OPENAI_API_KEY`).
* When present, the input's `aria-describedby` points at the hint element.
*/
envVarName?: string;
/** True when the key is satisfied by an inherited layer. */
isInherited: boolean;
/** Human-readable source of the inherited value. */
@@ -46,13 +54,22 @@ export function PersonaProviderApiKeyField({
value: string;
}) {
const [showValue, setShowValue] = React.useState(false);
const inputId = "persona-provider-api-key";
const uid = React.useId();
const inputId = `persona-provider-api-key-${uid}`;
const hintId = envVarName
? `persona-provider-api-key-hint-${uid}`
: undefined;
return (
<div className="space-y-1.5">
<RequiredFieldLabel htmlFor={inputId} isRequired={isRequired}>
{label}
</RequiredFieldLabel>
{envVarName ? (
<p className="text-xs text-muted-foreground font-mono" id={hintId}>
{envVarName}
</p>
) : null}
<div
className={cn(
"flex min-h-11 items-center gap-2 px-3",
@@ -60,6 +77,7 @@ export function PersonaProviderApiKeyField({
)}
>
<Input
aria-describedby={hintId}
autoComplete="off"
className={cn(
"h-8 flex-1 px-0 py-0 leading-6",
@@ -5,6 +5,7 @@ import {
getDefaultPersonaRuntime,
getPersonaModelOptions,
getPersonaProviderOptions,
getProviderApiKeyLabel,
resetConfigForHarnessChange,
runtimeSupportsLlmProviderSelection,
} from "./agentConfigOptions.tsx";
@@ -246,3 +247,50 @@ test("formatModelDiscoveryErrorStatus returns a non-null status for runtime unav
assert.ok(typeof status?.tone === "string", "status has a tone");
}
});
// ── getProviderApiKeyLabel — provider-accurate credential field labels ────────
//
// Each provider with a secretEnvVar must have a distinct label. The helper
// is the single source of truth used by all three credential field surfaces;
// if it regresses the field labels diverge silently and the OpenRouter / compat
// mislabeling recurs.
test("getProviderApiKeyLabel_anthropic_returns_anthropic_label", () => {
assert.equal(getProviderApiKeyLabel("anthropic"), "Anthropic API Key");
});
test("getProviderApiKeyLabel_openai_returns_openai_runtime_label", () => {
assert.equal(getProviderApiKeyLabel("openai"), "OpenAI Runtime API Key");
});
test("getProviderApiKeyLabel_openai_compat_returns_distinct_label", () => {
// openai and openai-compat must have distinct labels — both use
// OPENAI_COMPAT_API_KEY but carry different semantic identities.
assert.equal(
getProviderApiKeyLabel("openai-compat"),
"OpenAI-compatible Runtime API Key",
);
});
test("getProviderApiKeyLabel_openrouter_returns_openrouter_label", () => {
// Key fix: OpenRouter was mislabeled "OpenAI API Key" before this change.
assert.equal(getProviderApiKeyLabel("openrouter"), "OpenRouter API Key");
});
test("getProviderApiKeyLabel_databricks_returns_null", () => {
// Databricks uses OAuth PKCE — no typed-secret label.
assert.equal(getProviderApiKeyLabel("databricks"), null);
});
test("getProviderApiKeyLabel_databricks_v2_returns_null", () => {
assert.equal(getProviderApiKeyLabel("databricks_v2"), null);
});
test("getProviderApiKeyLabel_unknown_provider_returns_null", () => {
assert.equal(getProviderApiKeyLabel("some-unknown-provider"), null);
});
test("getProviderApiKeyLabel_provider_id_trimmed_and_lowercased", () => {
// Mirrors getProviderApiKeyEnvVar normalisation behaviour.
assert.equal(getProviderApiKeyLabel(" Anthropic "), "Anthropic API Key");
});
@@ -63,19 +63,30 @@ export type PersonaDropdownOption = {
*
* `requiredEnvKeys`: keys that must be present in the agent's effective env for
* the provider to work (surfaced as amber required rows in EnvVarsEditor).
* `secretEnvVar`: the one env key that holds a user-typed secret (API key).
* Only set for providers where the credential is a plaintext secret the user
* pastes in. Cleared automatically when the user switches away from the
* provider. Databricks uses OAuth PKCE (no typed secret), so it has no
* secretEnvVar.
* `secretEnvVar` + `apiKeyLabel`: paired — either both are present or neither
* is. `secretEnvVar` is the env key holding the user-typed secret; clearing
* it when the user switches providers ensures no orphaned credentials remain.
* Databricks uses OAuth PKCE (no typed secret), so it carries neither field.
* `apiKeyLabel` is the human-readable label shown in the credential field;
* derived by `getProviderApiKeyLabel` — single source of truth for all UI
* surfaces so they never drift.
*
* Mirrors the Rust `readiness::buzz_agent_requirements` /
* `readiness::goose_requirements` logic — keep in sync.
*/
export type ProviderCredentialConfig = {
requiredEnvKeys: readonly string[];
secretEnvVar?: string;
};
export type ProviderCredentialConfig =
| {
requiredEnvKeys: readonly string[];
secretEnvVar?: undefined;
apiKeyLabel?: undefined;
}
| {
requiredEnvKeys: readonly string[];
/** The env key holding the user-typed API secret. */
secretEnvVar: string;
/** Display label for the credential input field, e.g. "Anthropic API Key". */
apiKeyLabel: string;
};
/**
* Unified provider credential config table. Single source of truth for both
@@ -87,20 +98,23 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
anthropic: {
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
secretEnvVar: "ANTHROPIC_API_KEY",
apiKeyLabel: "Anthropic API Key",
},
openai: {
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
secretEnvVar: "OPENAI_COMPAT_API_KEY",
apiKeyLabel: "OpenAI Runtime API Key",
},
"openai-compat": {
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
secretEnvVar: "OPENAI_COMPAT_API_KEY",
apiKeyLabel: "OpenAI-compatible Runtime API Key",
},
databricks: {
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
requiredEnvKeys: ["DATABRICKS_HOST"],
// No secretEnvVar: DATABRICKS_HOST is a URL, not a secret credential, and
// is not cleared on provider switch (unlike API keys).
// No secretEnvVar / apiKeyLabel: DATABRICKS_HOST is a URL, not a secret
// credential, and is not cleared on provider switch (unlike API keys).
},
databricks_v2: {
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
@@ -113,6 +127,7 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
openrouter: {
requiredEnvKeys: ["OPENROUTER_API_KEY"],
secretEnvVar: "OPENROUTER_API_KEY",
apiKeyLabel: "OpenRouter API Key",
},
};
@@ -402,6 +417,31 @@ export function getProviderApiKeyEnvVar(providerId: string): string | null {
);
}
/**
* Returns the display label for the provider's API key field, if any.
* Derived from PROVIDER_CREDENTIAL_CONFIG.apiKeyLabel — single source of truth
* for all credential field labels so every surface stays in sync.
*
* Returns null when the provider has no typed-secret credential (e.g.,
* Databricks, which uses OAuth PKCE).
*/
export function getProviderApiKeyLabel(providerId: string): string | null {
return (
PROVIDER_CREDENTIAL_CONFIG[providerId.trim().toLowerCase()]?.apiKeyLabel ??
null
);
}
/**
* Muted contextual hint for the `OPENAI_API_KEY` row in env editors.
* Pass as `keyAnnotations` to every `EnvVarsEditor` that may surface this key
* (Agent Defaults, agent edit dialog, persona definition dialog). Exported
* so the constant is defined once and never duplicated across surfaces.
*/
export const CARD_MINT_KEY_ANNOTATIONS: Readonly<Record<string, string>> = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
export function shouldClearKnownModelForSelectionScope({
model,
provider,
@@ -21,7 +21,8 @@ test("model discovery status names missing OpenAI-compatible credentials", () =>
);
assert.equal(status?.tone, "warning");
assert.match(status?.message ?? "", /OpenAI API key/);
assert.match(status?.message ?? "", /OpenAI runtime API key/);
assert.match(status?.message ?? "", /OPENAI_COMPAT_API_KEY/);
assert.match(status?.message ?? "", /OpenAI models/);
});
@@ -110,7 +110,8 @@ export function formatModelDiscoveryErrorStatus(
if (message.includes("OPENAI_COMPAT_API_KEY required")) {
return {
message: "Enter an OpenAI API key to load OpenAI models.",
message:
"Enter an OpenAI runtime API key (OPENAI_COMPAT_API_KEY) to load OpenAI models.",
tone: "warning",
};
}
@@ -980,4 +980,44 @@ test.describe("global agent config screenshots", () => {
path: `${SHOTS}/11-edit-runtime-less-provider-required-save-blocked.png`,
});
});
// Will's exact stuck path: databricks_v2 global provider + saved global
// OPENAI_API_KEY. The cue must be visible without opening Advanced; once
// Advanced is opened the annotation must appear on the matching row.
test("card-mint-key-cue-visible-and-annotation-in-advanced", async ({
page,
}) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "databricks_v2",
model: null,
preferred_runtime: "buzz-agent",
env_vars: { OPENAI_API_KEY: "sk-placeholder" },
},
});
await openAiDefaultsSettings(page);
const card = page.getByTestId("settings-global-agent-config");
// The cue must be visible without the user opening Advanced.
await expect(card.getByTestId("card-mint-key-cue")).toBeVisible();
await expect(card.getByTestId("card-mint-key-cue")).toContainText(
"OPENAI_API_KEY",
);
await expect(card.getByTestId("card-mint-key-cue")).toContainText(
"Advanced → Environment variables",
);
// Advanced is collapsed at this point.
const advancedToggle = card.getByTestId("global-agent-advanced-toggle");
await expect(advancedToggle).toHaveAttribute("aria-expanded", "false");
// Open Advanced — the OPENAI_API_KEY row's annotation must be visible.
await advancedToggle.click();
await expect(advancedToggle).toHaveAttribute("aria-expanded", "true");
await expect(
card.getByText("Used for minting agent trading cards"),
).toBeVisible();
});
});
+2 -2
View File
@@ -329,7 +329,7 @@ test("persona model options follow the selected LLM provider", async ({
await selectDropdownOption(page, llmProvider, "OpenAI");
const dialog = page.getByRole("dialog");
await expect(dialog.getByLabel("OpenAI API Key")).toBeVisible();
await expect(dialog.getByLabel("OpenAI Runtime API Key")).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Advanced", exact: true }),
).toHaveAttribute("aria-expanded", "false");
@@ -343,7 +343,7 @@ test("persona model options follow the selected LLM provider", async ({
await selectDropdownOption(page, llmProvider, "Anthropic");
await expect(dialog.getByLabel("Anthropic API Key")).toBeVisible();
await expect(dialog.getByLabel("OpenAI API Key")).not.toBeVisible();
await expect(dialog.getByLabel("OpenAI Runtime API Key")).not.toBeVisible();
await expect(model).toBeVisible();
// Switch back to inherited defaults — per-agent provider, credential, and