mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg
parent
f810a2f49e
commit
5e0efb0bb9
@@ -32,9 +32,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
AUTO_PROVIDER_DROPDOWN_VALUE,
|
AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||||
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
||||||
|
CARD_MINT_KEY_ANNOTATIONS,
|
||||||
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
||||||
getPersonaProviderOptions,
|
getPersonaProviderOptions,
|
||||||
getProviderApiKeyEnvVar,
|
getProviderApiKeyEnvVar,
|
||||||
|
getProviderApiKeyLabel,
|
||||||
runtimeSupportsLlmProviderSelection,
|
runtimeSupportsLlmProviderSelection,
|
||||||
} from "@/features/agents/ui/agentConfigOptions";
|
} from "@/features/agents/ui/agentConfigOptions";
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +56,7 @@ import {
|
|||||||
} from "@/features/agents/ui/buzzAgentModelTuningFields";
|
} from "@/features/agents/ui/buzzAgentModelTuningFields";
|
||||||
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
|
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
|
||||||
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
|
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
|
||||||
|
import { CardMintKeyCue } from "./CardMintKeyCue";
|
||||||
import { getGlobalAgentCredentialState } from "./globalAgentCredentialState";
|
import { getGlobalAgentCredentialState } from "./globalAgentCredentialState";
|
||||||
|
|
||||||
export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
|
export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
|
||||||
@@ -74,7 +77,6 @@ const PROGRESSIVE_FIELDS_TRANSITION = {
|
|||||||
duration: 0.22,
|
duration: 0.22,
|
||||||
ease: [0.23, 1, 0.32, 1],
|
ease: [0.23, 1, 0.32, 1],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type AgentConfigDisclosure =
|
type AgentConfigDisclosure =
|
||||||
| "full"
|
| "full"
|
||||||
| "onboarding-essential"
|
| "onboarding-essential"
|
||||||
@@ -85,13 +87,9 @@ type AgentConfigDisclosure =
|
|||||||
// - auto-select a valid model when the provider changes
|
// - auto-select a valid model when the provider changes
|
||||||
// - keep the model select usable during discovery
|
// - keep the model select usable during discovery
|
||||||
// - preserve credential env vars across provider switches (the abandoned
|
// - preserve credential env vars across provider switches (the abandoned
|
||||||
// provider's key stays in env_vars — visible/deletable under Advanced —
|
// 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)
|
|
||||||
// - require a provider before model/effort are editable (no saveable
|
// - require a provider before model/effort are editable (no saveable
|
||||||
// invalid state — design principle #4). Note: legacy configs saved with
|
// invalid state — design principle #4)
|
||||||
// a model but no provider are cleared by the pre-existing orphan-model
|
|
||||||
// effect on next edit — deliberate data healing, documented in PR.
|
|
||||||
const autoSelectModelOnProviderChange = true;
|
const autoSelectModelOnProviderChange = true;
|
||||||
const disableModelSelectDuringDiscovery = false;
|
const disableModelSelectDuringDiscovery = false;
|
||||||
const preserveCredentialEnvVarsOnProviderChange = true;
|
const preserveCredentialEnvVarsOnProviderChange = true;
|
||||||
@@ -747,6 +745,7 @@ export function AgentConfigFields({
|
|||||||
<div className={blockClassName}>
|
<div className={blockClassName}>
|
||||||
<PersonaProviderApiKeyField
|
<PersonaProviderApiKeyField
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
envVarName={apiKeyEnvVar}
|
||||||
inheritedLabel={
|
inheritedLabel={
|
||||||
apiKeyFileSatisfied
|
apiKeyFileSatisfied
|
||||||
? "Set in runtime config"
|
? "Set in runtime config"
|
||||||
@@ -754,11 +753,7 @@ export function AgentConfigFields({
|
|||||||
}
|
}
|
||||||
isInherited={apiKeyInherited}
|
isInherited={apiKeyInherited}
|
||||||
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
|
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
|
||||||
label={
|
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
|
||||||
effectiveProvider === "anthropic"
|
|
||||||
? "Anthropic API Key"
|
|
||||||
: "OpenAI API Key"
|
|
||||||
}
|
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onConfigChange({
|
onConfigChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -869,6 +864,7 @@ export function AgentConfigFields({
|
|||||||
|
|
||||||
{showAdvancedFields ? (
|
{showAdvancedFields ? (
|
||||||
<div className={cn(blockClassName, "space-y-3")}>
|
<div className={cn(blockClassName, "space-y-3")}>
|
||||||
|
<CardMintKeyCue envVars={config.env_vars} />
|
||||||
<button
|
<button
|
||||||
aria-expanded={advancedOpen}
|
aria-expanded={advancedOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -912,6 +908,7 @@ export function AgentConfigFields({
|
|||||||
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
|
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
|
||||||
inheritedRows={bakedGenericRows}
|
inheritedRows={bakedGenericRows}
|
||||||
inheritedRowsLabel="build"
|
inheritedRowsLabel="build"
|
||||||
|
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
|
||||||
label="Environment variables"
|
label="Environment variables"
|
||||||
onChange={handleEnvVarsChange}
|
onChange={handleEnvVarsChange}
|
||||||
requiredKeys={advancedRequiredEnvKeys}
|
requiredKeys={advancedRequiredEnvKeys}
|
||||||
@@ -930,6 +927,7 @@ export function AgentConfigFields({
|
|||||||
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
|
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
|
||||||
inheritedRows={bakedGenericRows}
|
inheritedRows={bakedGenericRows}
|
||||||
inheritedRowsLabel="build"
|
inheritedRowsLabel="build"
|
||||||
|
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
|
||||||
label="Environment variables"
|
label="Environment variables"
|
||||||
onChange={handleEnvVarsChange}
|
onChange={handleEnvVarsChange}
|
||||||
requiredKeys={advancedRequiredEnvKeys}
|
requiredKeys={advancedRequiredEnvKeys}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
getDefaultPersonaRuntime,
|
getDefaultPersonaRuntime,
|
||||||
getPersonaModelOptions,
|
getPersonaModelOptions,
|
||||||
getPersonaProviderOptions,
|
getPersonaProviderOptions,
|
||||||
|
getProviderApiKeyLabel,
|
||||||
getRuntimePersonaModelOptions,
|
getRuntimePersonaModelOptions,
|
||||||
NO_RUNTIME_DROPDOWN_VALUE,
|
NO_RUNTIME_DROPDOWN_VALUE,
|
||||||
runtimeSupportsLlmProviderSelection,
|
runtimeSupportsLlmProviderSelection,
|
||||||
@@ -907,14 +908,11 @@ export function AgentDefinitionDialog({
|
|||||||
topLevelSecretEnvVar ? (
|
topLevelSecretEnvVar ? (
|
||||||
<PersonaProviderApiKeyField
|
<PersonaProviderApiKeyField
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
envVarName={topLevelSecretEnvVar}
|
||||||
isInherited={apiKeyIsInherited}
|
isInherited={apiKeyIsInherited}
|
||||||
inheritedLabel={apiKeyInheritedLabel}
|
inheritedLabel={apiKeyInheritedLabel}
|
||||||
isRequired={apiKeyIsRequired}
|
isRequired={apiKeyIsRequired}
|
||||||
label={
|
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
|
||||||
effectiveProvider === "anthropic"
|
|
||||||
? "Anthropic API key"
|
|
||||||
: "OpenAI API key"
|
|
||||||
}
|
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
setEnvVars((prev) => ({
|
setEnvVars((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ import {
|
|||||||
getBakedModelInheritLabel,
|
getBakedModelInheritLabel,
|
||||||
getBakedProviderInheritLabel,
|
getBakedProviderInheritLabel,
|
||||||
} from "./bakedEnvHelpers";
|
} from "./bakedEnvHelpers";
|
||||||
import { getProviderApiKeyEnvVar } from "./agentConfigOptions";
|
import {
|
||||||
|
getProviderApiKeyEnvVar,
|
||||||
|
getProviderApiKeyLabel,
|
||||||
|
} from "./agentConfigOptions";
|
||||||
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
|
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
|
||||||
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
|
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
|
||||||
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
|
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
|
||||||
@@ -1061,14 +1064,11 @@ export function AgentInstanceEditDialog({
|
|||||||
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
|
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
|
||||||
<PersonaProviderApiKeyField
|
<PersonaProviderApiKeyField
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
|
envVarName={topLevelSecretEnvVar}
|
||||||
isInherited={apiKeyIsInherited}
|
isInherited={apiKeyIsInherited}
|
||||||
inheritedLabel={apiKeyInheritedLabel}
|
inheritedLabel={apiKeyInheritedLabel}
|
||||||
isRequired={apiKeyIsRequired}
|
isRequired={apiKeyIsRequired}
|
||||||
label={
|
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
|
||||||
effectiveProvider === "anthropic"
|
|
||||||
? "Anthropic API Key"
|
|
||||||
: "OpenAI API Key"
|
|
||||||
}
|
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
setEnvVars((prev) => ({
|
setEnvVars((prev) => ({
|
||||||
...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 { Textarea } from "@/shared/ui/textarea";
|
||||||
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
|
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
|
||||||
import {
|
import {
|
||||||
|
CARD_MINT_KEY_ANNOTATIONS,
|
||||||
PERSONA_FIELD_CONTROL_CLASS,
|
PERSONA_FIELD_CONTROL_CLASS,
|
||||||
PERSONA_FIELD_SHELL_CLASS,
|
PERSONA_FIELD_SHELL_CLASS,
|
||||||
PERSONA_LABEL_OPTIONAL_CLASS,
|
PERSONA_LABEL_OPTIONAL_CLASS,
|
||||||
@@ -247,6 +248,7 @@ export function EditAgentAdvancedFields({
|
|||||||
helperText="Per-agent env vars. Override the template's vars on collision."
|
helperText="Per-agent env vars. Override the template's vars on collision."
|
||||||
inheritedFrom={inheritedEnvVars}
|
inheritedFrom={inheritedEnvVars}
|
||||||
inheritedLabel="template / global defaults"
|
inheritedLabel="template / global defaults"
|
||||||
|
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
|
||||||
onChange={onEnvVarsChange}
|
onChange={onEnvVarsChange}
|
||||||
requiredKeys={requiredEnvKeys}
|
requiredKeys={requiredEnvKeys}
|
||||||
value={envVars}
|
value={envVars}
|
||||||
|
|||||||
@@ -523,3 +523,97 @@ test("getBakedProviderInheritLabel_empty_options_falls_back_to_raw_id", () => {
|
|||||||
"empty options table must fall 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[];
|
inheritedRows?: readonly InheritedEnvRow[];
|
||||||
/** Label for the inherited-row tag (e.g. "build"). Defaults to "build". */
|
/** Label for the inherited-row tag (e.g. "build"). Defaults to "build". */
|
||||||
inheritedRowsLabel?: string;
|
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 };
|
type Row = { id: string; key: string; value: string };
|
||||||
@@ -191,6 +199,7 @@ export function EnvVarsEditor({
|
|||||||
focusKey,
|
focusKey,
|
||||||
inheritedRows = [],
|
inheritedRows = [],
|
||||||
inheritedRowsLabel = "build",
|
inheritedRowsLabel = "build",
|
||||||
|
keyAnnotations,
|
||||||
}: EnvVarsEditorProps) {
|
}: EnvVarsEditorProps) {
|
||||||
// Keys that render as their own special rows (required amber rows or
|
// Keys that render as their own special rows (required amber rows or
|
||||||
// file-satisfied read-only rows). These must NEVER enter `rows` state —
|
// file-satisfied read-only rows). These must NEVER enter `rows` state —
|
||||||
@@ -406,6 +415,14 @@ export function EnvVarsEditor({
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{keyAnnotations?.[key] ? (
|
||||||
|
<p
|
||||||
|
className="ml-1 text-xs text-muted-foreground"
|
||||||
|
data-testid="env-vars-key-annotation"
|
||||||
|
>
|
||||||
|
{keyAnnotations[key]}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -596,6 +613,14 @@ export function EnvVarsEditor({
|
|||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { PersonaBehaviorDraft } from "./personaBehaviorDraft";
|
|||||||
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
|
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
|
||||||
import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields";
|
import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields";
|
||||||
import {
|
import {
|
||||||
|
CARD_MINT_KEY_ANNOTATIONS,
|
||||||
PERSONA_FIELD_CONTROL_CLASS,
|
PERSONA_FIELD_CONTROL_CLASS,
|
||||||
PERSONA_FIELD_SHELL_CLASS,
|
PERSONA_FIELD_SHELL_CLASS,
|
||||||
PERSONA_LABEL_OPTIONAL_CLASS,
|
PERSONA_LABEL_OPTIONAL_CLASS,
|
||||||
@@ -142,6 +143,7 @@ export function PersonaAdvancedFields({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
fileSatisfiedKeys={fileSatisfiedEnvKeys}
|
fileSatisfiedKeys={fileSatisfiedEnvKeys}
|
||||||
hiddenKeys={hiddenEnvKeys}
|
hiddenKeys={hiddenEnvKeys}
|
||||||
|
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
|
||||||
onChange={onEnvVarsChange}
|
onChange={onEnvVarsChange}
|
||||||
requiredKeys={requiredEnvKeys}
|
requiredKeys={requiredEnvKeys}
|
||||||
value={envVars}
|
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({
|
export function PersonaProviderApiKeyField({
|
||||||
disabled,
|
disabled,
|
||||||
|
envVarName,
|
||||||
isInherited,
|
isInherited,
|
||||||
inheritedLabel,
|
inheritedLabel,
|
||||||
isRequired,
|
isRequired,
|
||||||
@@ -33,6 +34,13 @@ export function PersonaProviderApiKeyField({
|
|||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
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. */
|
/** True when the key is satisfied by an inherited layer. */
|
||||||
isInherited: boolean;
|
isInherited: boolean;
|
||||||
/** Human-readable source of the inherited value. */
|
/** Human-readable source of the inherited value. */
|
||||||
@@ -46,13 +54,22 @@ export function PersonaProviderApiKeyField({
|
|||||||
value: string;
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
const [showValue, setShowValue] = React.useState(false);
|
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 (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<RequiredFieldLabel htmlFor={inputId} isRequired={isRequired}>
|
<RequiredFieldLabel htmlFor={inputId} isRequired={isRequired}>
|
||||||
{label}
|
{label}
|
||||||
</RequiredFieldLabel>
|
</RequiredFieldLabel>
|
||||||
|
{envVarName ? (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono" id={hintId}>
|
||||||
|
{envVarName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-11 items-center gap-2 px-3",
|
"flex min-h-11 items-center gap-2 px-3",
|
||||||
@@ -60,6 +77,7 @@ export function PersonaProviderApiKeyField({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
aria-describedby={hintId}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 flex-1 px-0 py-0 leading-6",
|
"h-8 flex-1 px-0 py-0 leading-6",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getDefaultPersonaRuntime,
|
getDefaultPersonaRuntime,
|
||||||
getPersonaModelOptions,
|
getPersonaModelOptions,
|
||||||
getPersonaProviderOptions,
|
getPersonaProviderOptions,
|
||||||
|
getProviderApiKeyLabel,
|
||||||
resetConfigForHarnessChange,
|
resetConfigForHarnessChange,
|
||||||
runtimeSupportsLlmProviderSelection,
|
runtimeSupportsLlmProviderSelection,
|
||||||
} from "./agentConfigOptions.tsx";
|
} 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");
|
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
|
* `requiredEnvKeys`: keys that must be present in the agent's effective env for
|
||||||
* the provider to work (surfaced as amber required rows in EnvVarsEditor).
|
* the provider to work (surfaced as amber required rows in EnvVarsEditor).
|
||||||
* `secretEnvVar`: the one env key that holds a user-typed secret (API key).
|
* `secretEnvVar` + `apiKeyLabel`: paired — either both are present or neither
|
||||||
* Only set for providers where the credential is a plaintext secret the user
|
* is. `secretEnvVar` is the env key holding the user-typed secret; clearing
|
||||||
* pastes in. Cleared automatically when the user switches away from the
|
* it when the user switches providers ensures no orphaned credentials remain.
|
||||||
* provider. Databricks uses OAuth PKCE (no typed secret), so it has no
|
* Databricks uses OAuth PKCE (no typed secret), so it carries neither field.
|
||||||
* secretEnvVar.
|
* `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` /
|
* Mirrors the Rust `readiness::buzz_agent_requirements` /
|
||||||
* `readiness::goose_requirements` logic — keep in sync.
|
* `readiness::goose_requirements` logic — keep in sync.
|
||||||
*/
|
*/
|
||||||
export type ProviderCredentialConfig = {
|
export type ProviderCredentialConfig =
|
||||||
requiredEnvKeys: readonly string[];
|
| {
|
||||||
secretEnvVar?: string;
|
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
|
* Unified provider credential config table. Single source of truth for both
|
||||||
@@ -87,20 +98,23 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
|
|||||||
anthropic: {
|
anthropic: {
|
||||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||||
secretEnvVar: "ANTHROPIC_API_KEY",
|
secretEnvVar: "ANTHROPIC_API_KEY",
|
||||||
|
apiKeyLabel: "Anthropic API Key",
|
||||||
},
|
},
|
||||||
openai: {
|
openai: {
|
||||||
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
||||||
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
||||||
|
apiKeyLabel: "OpenAI Runtime API Key",
|
||||||
},
|
},
|
||||||
"openai-compat": {
|
"openai-compat": {
|
||||||
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
||||||
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
secretEnvVar: "OPENAI_COMPAT_API_KEY",
|
||||||
|
apiKeyLabel: "OpenAI-compatible Runtime API Key",
|
||||||
},
|
},
|
||||||
databricks: {
|
databricks: {
|
||||||
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
|
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
|
||||||
requiredEnvKeys: ["DATABRICKS_HOST"],
|
requiredEnvKeys: ["DATABRICKS_HOST"],
|
||||||
// No secretEnvVar: DATABRICKS_HOST is a URL, not a secret credential, and
|
// No secretEnvVar / apiKeyLabel: DATABRICKS_HOST is a URL, not a secret
|
||||||
// is not cleared on provider switch (unlike API keys).
|
// credential, and is not cleared on provider switch (unlike API keys).
|
||||||
},
|
},
|
||||||
databricks_v2: {
|
databricks_v2: {
|
||||||
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
|
// DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path.
|
||||||
@@ -113,6 +127,7 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial<
|
|||||||
openrouter: {
|
openrouter: {
|
||||||
requiredEnvKeys: ["OPENROUTER_API_KEY"],
|
requiredEnvKeys: ["OPENROUTER_API_KEY"],
|
||||||
secretEnvVar: "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({
|
export function shouldClearKnownModelForSelectionScope({
|
||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ test("model discovery status names missing OpenAI-compatible credentials", () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(status?.tone, "warning");
|
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/);
|
assert.match(status?.message ?? "", /OpenAI models/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export function formatModelDiscoveryErrorStatus(
|
|||||||
|
|
||||||
if (message.includes("OPENAI_COMPAT_API_KEY required")) {
|
if (message.includes("OPENAI_COMPAT_API_KEY required")) {
|
||||||
return {
|
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",
|
tone: "warning",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -980,4 +980,44 @@ test.describe("global agent config screenshots", () => {
|
|||||||
path: `${SHOTS}/11-edit-runtime-less-provider-required-save-blocked.png`,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ test("persona model options follow the selected LLM provider", async ({
|
|||||||
|
|
||||||
await selectDropdownOption(page, llmProvider, "OpenAI");
|
await selectDropdownOption(page, llmProvider, "OpenAI");
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
await expect(dialog.getByLabel("OpenAI API Key")).toBeVisible();
|
await expect(dialog.getByLabel("OpenAI Runtime API Key")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
dialog.getByRole("button", { name: "Advanced", exact: true }),
|
dialog.getByRole("button", { name: "Advanced", exact: true }),
|
||||||
).toHaveAttribute("aria-expanded", "false");
|
).toHaveAttribute("aria-expanded", "false");
|
||||||
@@ -343,7 +343,7 @@ test("persona model options follow the selected LLM provider", async ({
|
|||||||
|
|
||||||
await selectDropdownOption(page, llmProvider, "Anthropic");
|
await selectDropdownOption(page, llmProvider, "Anthropic");
|
||||||
await expect(dialog.getByLabel("Anthropic API Key")).toBeVisible();
|
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();
|
await expect(model).toBeVisible();
|
||||||
|
|
||||||
// Switch back to inherited defaults — per-agent provider, credential, and
|
// Switch back to inherited defaults — per-agent provider, credential, and
|
||||||
|
|||||||
Reference in New Issue
Block a user