Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,15 @@ const PromptModelConfigs = ({
<DropdownMenu>
<TooltipWrapper content="Model parameters">
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size} disabled={disabled}>
{/* Icon-only, so it has no accessible name to address it by — the
"Model parameters" string lives in the tooltip, which does not
contribute one. Test-only hook; nothing reads it at runtime. */}
<Button
variant={variant}
size={size}
disabled={disabled}
data-testid="model-parameters-trigger"
>
<Settings2 />
</Button>
</DropdownMenuTrigger>
Expand Down
5 changes: 3 additions & 2 deletions tests_end_to_end/coverage/taxonomy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,9 @@ areas:
- online-evaluation/online-evaluation-sampling-rate.spec.ts
- online-evaluation/online-evaluation-python-metric-errors.spec.ts
- online-evaluation/online-evaluation-non-object-sections.spec.ts
- online-evaluation/online-evaluation-thinking-level.spec.ts
capabilities:
create-llm-judge-rule: { covered: true, tier: t1-smoke }
create-llm-judge-rule: { covered: true, tier: t1-smoke, note: "t2 also covers creation on a Gemini model, asserting the model-settings pane's preselected thinking level and offered levels against what the save persists" }
llm-judge-scores: { covered: true, tier: t1-smoke, note: "bimodal safe/unsafe" }
create-python-rule: { covered: true, tier: t1-smoke }
python-rule-scores: { covered: true, tier: t1-smoke, note: "deterministic 3x1.0 / 2x0.0; t2 also covers sub-path mappings over non-object sections and the 400-class classification of a metric that exits 0 without a result line" }
Expand All @@ -659,7 +660,7 @@ areas:
rule-filters: { covered: false }
sampling-rate: { covered: true, tier: t2-cuj, note: "50% rule vs 100% control over one 30-trace batch, binomial band 15-85%; plus a 0%-rate rule at trigger_scope=both, which must skip every SDK trace and still score experiment/playground/optimization ones" }
clone-rule: { covered: false }
edit-rule: { covered: false }
edit-rule: { covered: true, tier: t2-cuj, note: "row kebab -> Edit -> submit, asserted on the persisted evaluator: the Gemini thinking level round-trips (saved -> rehydrated -> resaved), an untouched resave preserves it, and setting it back to None removes the block rather than leaving it behind. Only the model-settings pane of the dialog; the prompt, variable mapping, filters and sampling controls are still unexercised by an edit" }
enable-disable-rule: { covered: true, tier: t2-cuj, note: "edit-dialog switch; control rule proves scoring stopped, then resumed" }
delete-rule: { covered: true, tier: t2-cuj, note: "row kebab delete; control rule proves scoring stopped" }
# Deliberately still false. online-evaluation-python-metric-errors.spec.ts
Expand Down
57 changes: 57 additions & 0 deletions tests_end_to_end/e2e/core/backend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ export interface AutomationRuleDetail {
triggerScope: string;
}

/**
* The `code.model` block of an LLM-as-judge rule, as the REST view answers it.
*
* Read raw rather than through the pinned SDK for the same reason as
* `AutomationRuleDetail`: the SDK's evaluator shape has no
* `custom_parameters`, and this is the field that decides what the judge call
* actually sends to the provider.
*/
export interface LlmJudgeModelDetail {
/** The model id the rule will call, e.g. `gemini-3.5-flash-lite`. */
name: string;
/**
* `custom_parameters` verbatim, `null` when the rule carries none.
*
* Kept nullable rather than defaulted to `{}` because absent and empty are
* different answers here: a thinking level of "none" must REMOVE the block,
* and a spec that cannot tell "no block" from "empty block" cannot assert
* that.
*/
customParameters: Record<string, unknown> | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REST response type hides wire-format field

The handwritten raw REST type exposes backend custom_parameters as customParameters, while getLlmJudgeModel reads model.custom_parameters, so the type no longer matches the payload and callers rely on an undocumented mapping — should we rename the field and returned object to custom_parameters, or introduce a separately mapped camelCase DTO?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/core/backend/client.ts around lines 203 and 1631-1637, update the
`LlmJudgeModelDetail` interface and `getLlmJudgeModel` method so the raw REST field
remains named `custom_parameters`, matching the backend payload and
`model.custom_parameters` access. Remove the undocumented camelCase transformation; if a
camelCase domain DTO is needed, define it separately and map to it explicitly.

}

/** One line of a rule's user-facing log stream. */
export interface AutomationRuleLogRef {
level: string;
Expand Down Expand Up @@ -1580,6 +1602,41 @@ export function makeBackendClient(apiKey: string | null = null, workspaceName: s
};
},

/**
* The model block of an LLM-as-judge rule — the model id and the
* `custom_parameters` the judge call will carry.
*
* Throws rather than defaulting when the rule is not an LLM-as-judge one:
* a Python-code rule has no `code.model` at all, and answering `null` for
* it would let a mis-typed spec assert "no custom parameters" about a rule
* that could never have had any.
*/
async getLlmJudgeModel(ruleId: string): Promise<LlmJudgeModelDetail> {
const { status, message, json } = await rawFetch(
'GET',
`/v1/private/automations/evaluators/${ruleId}`,
);
if (status !== 200) {
throw new Error(`getLlmJudgeModel: ${ruleId} answered ${status}: ${message}`);
}
const model = (json as { code?: { model?: unknown } } | null)?.code?.model as
| { name?: unknown; custom_parameters?: unknown }
| undefined;
if (!model || typeof model.name !== 'string') {
throw new Error(
`getLlmJudgeModel: ${ruleId} carries no code.model.name — not an LLM-as-judge rule?`,
);
}
const custom = model.custom_parameters;
return {
name: model.name,
customParameters:
custom === null || custom === undefined
? null
: (custom as Record<string, unknown>),
Comment on lines +1633 to +1636

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepts malformed custom parameters

customParameters casts every non-null JSON value to Record<string, unknown>, so scalars and arrays reach object-only consumers and violate the accessor contract — should we require typeof custom === 'object' && !Array.isArray(custom) and throw a clear error for other non-null shapes?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/core/backend/client.ts around lines 1633-1636, update
`getLlmJudgeModel` so `custom_parameters` is not blindly cast to `Record<string,
unknown>`. Preserve `null` for null or undefined, validate that other values are
non-null objects and not arrays, and throw a clear error identifying the invalid
`custom_parameters` shape before returning it.

};
},

/**
* A rule's user-facing log stream — the lines `/automation-logs` renders.
*
Expand Down
1 change: 1 addition & 0 deletions tests_end_to_end/e2e/core/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
type AutomationRuleRef,
type AutomationRuleDetail,
type AutomationRuleLogRef,
type LlmJudgeModelDetail,
type TraceJsonSection,
type AnnotationQueueDetail,
type AnnotationQueueReviewerRef,
Expand Down
55 changes: 46 additions & 9 deletions tests_end_to_end/e2e/core/provider-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,52 @@ function endpoint(path = ''): string {
return `${env.apiBaseUrl}/v1/private/llm-provider-key${path}`;
}

export async function findProviderKeyByName(providerName: string): Promise<ProviderKeyRef | null> {
async function listProviderKeys(): Promise<ProviderKeyRef[]> {
const response = await fetch(endpoint(), { headers: restHeaders() });
if (!response.ok) throw new Error(`list provider keys returned ${response.status}`);
const body = (await response.json()) as { content: ProviderKeyRef[] };
return body.content.find((key) => key.provider_name === providerName) ?? null;
return body.content;
Comment on lines 55 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed list responses cause opaque lookup crashes

listProviderKeys casts parsed JSON and returns body.content without checking that content is an array, so malformed successful responses reach .find and crash with an opaque TypeError — should we validate the response shape at the REST boundary before returning it?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/e2e/core/provider-keys.ts` around lines 55-56, update
`listProviderKeys` so it does not blindly cast the parsed response and return
`body.content`. Validate that the response is an object with a `content` property that
is an array before returning it; otherwise throw a descriptive error indicating the list
response has an invalid shape, so `findProviderKeyByName` and
`findProviderKeyByProvider` never call `.find` on malformed data.

}

export async function findProviderKeyByName(providerName: string): Promise<ProviderKeyRef | null> {
const keys = await listProviderKeys();
return keys.find((key) => key.provider_name === providerName) ?? null;
}

/**
* Find a key by its PROVIDER slug (`gemini`, `vertex-ai`, …) rather than by
* `provider_name`, which only the custom/bedrock/ollama providers carry.
*
* The workspace holds at most one key per built-in provider, so this is the
* only way to ask "is Gemini already configured here" — and a caller that
* seeds one has to ask, because creating a second would collide with whatever
* key the environment already had.
*/
export async function findProviderKeyByProvider(provider: string): Promise<ProviderKeyRef | null> {
const keys = await listProviderKeys();
return keys.find((key) => key.provider === provider) ?? null;
}

/**
* Create a provider key, and report its id from the `Location` header —
* creation answers 201 with an empty body, so that header is the only place
* the id appears.
*
* `null` rather than a throw when the header is missing, so a caller that
* cleans up by `provider_name` is not made to care: only a caller that has to
* address the key by id needs the header, and that caller is the one that
* should complain about it.
*/
export async function createProviderKey(payload: {
provider: string;
provider_name: string;
base_url: string;
/** Only the custom/bedrock/ollama providers name their keys. */
provider_name?: string;
base_url?: string;
/** The provider secret. Built-in providers key off this rather than `auth_config`. */
api_key?: string;
configuration?: Record<string, string>;
auth_config?: ProviderAuthConfig;
}): Promise<void> {
}): Promise<string | null> {
const response = await fetch(endpoint(), {
method: 'POST',
headers: restHeaders(),
Expand All @@ -71,21 +103,26 @@ export async function createProviderKey(payload: {
if (!response.ok) {
throw new Error(`create provider key returned ${response.status}: ${await response.text()}`);
}
return response.headers.get('location')?.split('/').filter(Boolean).pop() ?? null;
}

export async function deleteProviderKeyByName(providerName: string): Promise<void> {
const found = await findProviderKeyByName(providerName);
if (!found) return;
export async function deleteProviderKeyById(id: string): Promise<void> {
const response = await fetch(endpoint('/delete'), {
method: 'POST',
headers: restHeaders(),
body: JSON.stringify({ ids: [found.id] }),
body: JSON.stringify({ ids: [id] }),
});
if (!response.ok) {
throw new Error(`delete provider key returned ${response.status}`);
}
}

export async function deleteProviderKeyByName(providerName: string): Promise<void> {
const found = await findProviderKeyByName(providerName);
if (!found) return;
await deleteProviderKeyById(found.id);
}

/** Carries the HTTP status so callers can classify a failure instead of parsing prose. */
export class AuthConfigCheckError extends Error {
constructor(
Expand Down
1 change: 1 addition & 0 deletions tests_end_to_end/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type {
OauthProviderSeed,
ProviderKeysFixture,
ProviderKeyFixtures,
GeminiProviderKeyRef,
} from './provider-key.fixture';
export type { ProjectFixtures } from './project.fixture';
export type {
Expand Down
78 changes: 77 additions & 1 deletion tests_end_to_end/e2e/fixtures/provider-key.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
mockGatewayUrlForBackend,
mockTokenUrlForBackend,
} from '../core/mock-auth';
import { createProviderKey, deleteProviderKeyByName } from '../core/provider-keys';
import {
createProviderKey,
deleteProviderKeyById,
deleteProviderKeyByName,
findProviderKeyByProvider,
} from '../core/provider-keys';

export interface OauthProviderSeed {
providerName: string;
Expand All @@ -27,10 +32,37 @@ export interface ProviderKeysFixture {
register(providerName: string): void;
}

export interface GeminiProviderKeyRef {
/** The provider slug the API and the model picker agree on. */
provider: 'gemini';
/** The picker's group label for this provider — the scope a model lookup needs. */
groupLabel: 'Gemini';
/**
* True when this fixture created the key (and will delete it), false when it
* adopted one the environment already had (and will leave it alone).
*/
seeded: boolean;
}

export interface ProviderKeyFixtures {
providerKeys: ProviderKeysFixture;
/**
* Makes the Gemini models selectable in every model picker, without calling
* an LLM: the key holds a dummy secret, which is enough for the FE — the
* picker lists a provider's models as soon as a key for it exists.
*
* Only for specs that assert on FORM state and on what a save persists. A
* spec that needs a completion needs a real key and must not use this.
*/
geminiProviderKey: GeminiProviderKeyRef;
}

/**
* Not a real credential, and deliberately shaped so that it reads as one in a
* request log: nothing this fixture supports ever reaches Google.
*/
const DUMMY_GEMINI_API_KEY = 'e2e-dummy-key-no-completions-are-made-with-this';

/**
* Provider keys are WORKSPACE-GLOBAL, so every spec must use testNamespace-prefixed
* names and delete what it creates — this fixture owns the delete half.
Expand Down Expand Up @@ -74,6 +106,50 @@ export const test = baseTest.extend<ProviderKeyFixtures>({
}
}
},

// eslint-disable-next-line no-empty-pattern
geminiProviderKey: async ({}, use, testInfo) => {
// A workspace holds at most one key per built-in provider and the name
// cannot be namespaced the way `providerKeys.createOauth` namespaces a
// custom provider, so this is adopt-or-seed rather than seed: if the
// environment already has a Gemini key (a real one on a CI workspace, say)
// it is used as-is and left in place. Deleting a key this fixture did not
// create would break whatever configured it.
//
// Because the resource is workspace-global and unnamespaced, specs using
// this fixture must not run concurrently with each other — see the serial
// mode on online-evaluation-thinking-level.spec.ts.
//
// The other writer of this same key is playground-providers.spec.ts, which
// self-provisions Gemini through the AI Providers UI and then wants a real
// completion from it. The two do not collide today because that spec is
// @provider-sanity, which runs on its own cadence rather than in the tier
// ladder — but running both at once would let it adopt this dummy key and
// fail on auth. Keep them in separate runs.
let createdId: string | null = null;
if (!(await findProviderKeyByProvider('gemini'))) {
createdId = await createProviderKey({ provider: 'gemini', api_key: DUMMY_GEMINI_API_KEY });
if (createdId === null) {
// Refusing to continue rather than leaking: without the id this
// fixture cannot delete the key it just created, and a stray Gemini
// key changes what every later run's model picker offers.
throw new Error(
'gemini provider key was created but answered no Location header — ' +
'it cannot be addressed for teardown',
);
}
}

await use({ provider: 'gemini', groupLabel: 'Gemini', seeded: createdId !== null });

if (createdId !== null && !shouldLeaveArtifacts(testInfo)) {
try {
await deleteProviderKeyById(createdId);
} catch (err) {
console.warn('[provider-key fixture] gemini key delete warning:', err);
}
}
},
});

export { expect } from './automation-rules.fixture';
Loading
Loading