Skip to content
Merged
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
100 changes: 54 additions & 46 deletions spire-ui/src/components/SettingsLlmModelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,57 +131,65 @@ export default function SettingsLlmModelForm({ initial, onClose, onSaved }: Sett

return (
<div className="modal-overlay">
<div className="modal tall" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="modal tall model-dialog" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<h3>{editing ? 'Edit model' : 'Add model'}</h3>
<form className="modal-body" onSubmit={submit}>
<div className="field-row-2">
<label className="field">
<span>Type</span>
<Select
ariaLabel="Type"
value={identity.type}
options={LLM_TYPES.map((t) => ({ value: t, label: t }))}
onChange={(v) => updateIdentity({ type: v as LlmType })}
<div className="model-panes">
<div className="model-pane">
<div className="field-row-2">
<label className="field">
<span>Type</span>
<Select
ariaLabel="Type"
value={identity.type}
options={LLM_TYPES.map((t) => ({ value: t, label: t }))}
onChange={(v) => updateIdentity({ type: v as LlmType })}
/>
</label>
<label className="field">
<span>Model name</span>
<input
className="mono"
placeholder="gpt-4o"
value={identity.name}
onChange={(e) => updateIdentity({ name: e.target.value })}
/>
</label>
</div>

<label className="field">
<span>
Label <span className="field-optional">defaults to the model name</span>
</span>
<input placeholder="GPT-4o" value={identity.label} onChange={(e) => updateIdentity({ label: e.target.value })} />
</label>

<label className="field-check">
<input
type="checkbox"
checked={pricingMode === 'UNMETERED'}
onChange={(e) => setPricingMode(e.target.checked ? 'UNMETERED' : 'METERED')}
/>
<span>Self-hosted — no per-token cost (UNMETERED)</span>
</label>

<SettingsLlmModelDialectFields
type={identity.type}
dialect={dialect}
onDialectChange={updateDialect}
extraParams={extraParams}
onExtraParamsChange={setExtraParams}
/>
</label>
<label className="field">
<span>Model name</span>
<input
className="mono"
placeholder="gpt-4o"
value={identity.name}
onChange={(e) => updateIdentity({ name: e.target.value })}
/>
</label>
</div>

<section className="model-prices" aria-label="Prices">
<div className="model-prices-title">Prices · $ per 1M tokens</div>
{pricingMode === 'METERED'
? <ModelRateFields rates={rates} onChange={setRate} notBilled={notBilled} onNotBilled={assert} />
: <small className="field-hint">Self-hosted: this model has no per-token price.</small>}
</section>
</div>

<label className="field">
<span>
Label <span className="field-optional">defaults to the model name</span>
</span>
<input placeholder="GPT-4o" value={identity.label} onChange={(e) => updateIdentity({ label: e.target.value })} />
</label>

<label className="field-check">
<input
type="checkbox"
checked={pricingMode === 'UNMETERED'}
onChange={(e) => setPricingMode(e.target.checked ? 'UNMETERED' : 'METERED')}
/>
<span>Self-hosted — no per-token cost (UNMETERED)</span>
</label>

{pricingMode === 'METERED' && <ModelRateFields rates={rates} onChange={setRate}
notBilled={notBilled} onNotBilled={assert} />}

<SettingsLlmModelDialectFields
type={identity.type}
dialect={dialect}
onDialectChange={updateDialect}
extraParams={extraParams}
onExtraParamsChange={setExtraParams}
/>

{error && <div className="modal-msg modal-error">{error}</div>}

<div className="modal-actions">
Expand Down
44 changes: 24 additions & 20 deletions spire-ui/src/components/SettingsLlmModelRateFields.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId } from 'react';
import type { LlmModelView, PricingMode } from '../api';
import { dollarsToMillicentsPerMillion, millicentsPerMillionToDollars } from '../money';
import { MANDATORY_RATE_TYPES, RATE_TYPES, TOKEN_TYPE_LABEL, type RateType } from '../llmPricing';
Expand Down Expand Up @@ -85,24 +86,28 @@ interface RateFieldProps {
}

/** One rate input, mapped over {@link RATE_TYPES} rather than hand-written per dimension — the label,
* the optional/mandatory marker and the placeholder all already come from shared, tested tables. */
* the optional/mandatory marker and the placeholder all already come from shared, tested tables. The
* row shows only the type's name and a star when it is required; the input's own name still says the
* whole thing ("Input rate $ / 1M tokens, required"), which a screen reader and the tests both read. */
function RateField({ type, value, onChange, notBilled, onNotBilled }: RateFieldProps) {
const optional = !MANDATORY_RATE_TYPES.includes(type);
const id = useId();
const mandatory = MANDATORY_RATE_TYPES.includes(type);
return (
<div className="rate-field">
<label className="field">
<span className="rate-label">
{TOKEN_TYPE_LABEL[type]} rate $ / 1M tokens {optional && <span className="field-optional">optional</span>}
</span>
<input
className="mono"
inputMode="decimal"
placeholder={RATE_PLACEHOLDER[type]}
value={notBilled ? '' : value}
disabled={notBilled}
onChange={(e) => onChange(e.target.value)}
/>
<div className="price-item">
<label className="price-name" htmlFor={id}>
{TOKEN_TYPE_LABEL[type]}
{mandatory && <span className="price-required" aria-hidden="true"> *</span>}
</label>
<input
id={id}
className="mono price-input"
aria-label={`${TOKEN_TYPE_LABEL[type]} rate $ / 1M tokens, ${mandatory ? 'required' : 'optional'}`}
inputMode="decimal"
placeholder={RATE_PLACEHOLDER[type]}
value={notBilled ? '' : value}
disabled={notBilled}
onChange={(e) => onChange(e.target.value)}
/>
{/* An assertion, not a zero: the operator says this vendor charges nothing for this type. A run
that reports a type with neither a rate nor this box stops the item with an unknown cost. */}
{(
Expand Down Expand Up @@ -132,17 +137,16 @@ interface ModelRateFieldsProps {
export default function ModelRateFields({ rates, onChange, notBilled, onNotBilled }: ModelRateFieldsProps) {
return (
<>
<div className="rate-grid">
<div className="price-list">
{RATE_TYPES.map((type) => (
<RateField key={type} type={type} value={rates[type]} onChange={(v) => onChange(type, v)}
notBilled={!!notBilled[type]} onNotBilled={(v) => onNotBilled(type, v)} />
))}
</div>
<small className="field-hint">
Enter the provider's current published price per 1M tokens for each dimension it bills — used to
cost each run. A dimension left blank means nobody has said what it costs, and a run that reports
it stops with an unknown cost. Tick "the vendor does not bill this" to say so on purpose; input
and output need one or the other before the model can be saved.
Enter the provider's current published price. <span className="price-required">*</span> needs a
rate or "not billed". A rate left blank means nobody has said what it costs, and a run that
reports it stops with an unknown cost.
</small>
</>
);
Expand Down
36 changes: 27 additions & 9 deletions spire-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1147,14 +1147,32 @@
border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; word-break: break-all;
}
.field-optional { color: var(--text-3); font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 11px; }
/* The price fields line up across both columns whether or not a label wraps for its "optional" marker:
each field spans three rows of the grid (label, input, check box) as a subgrid, so the two labels of a
row share one track and sit at its bottom. The label element is display: contents so its text and its
input can each take a row; it still labels the input. */
.rate-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.rate-field { display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 6px; }
.rate-field > label.field { display: contents; }
.rate-label { align-self: end; display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; }
/* The model dialog in two panes: the model and its API settings on the left, the prices in their own
pane on the right (operator's choice of three mockups, 2026-09-27). One column was too tall for a
laptop window, and a price input half the dialog wide was far wider than any rate. */
.modal.model-dialog { max-width: 880px; }
.model-panes { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; align-items: start; }
.model-pane { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.model-prices {
display: flex; flex-direction: column; gap: 12px; padding: 14px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
}
.model-prices-title { font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.price-list { display: flex; flex-direction: column; gap: 10px; }
.price-item { display: grid; grid-template-columns: minmax(0, 1fr) 110px; align-items: center; column-gap: 10px; row-gap: 4px; }
.price-item > .field-check { grid-column: 1 / -1; font-size: 12px; color: var(--text-2); }
.price-name { font-size: 13px; color: var(--text); }
.price-required { color: var(--warn); font-family: var(--font-mono); }
.price-input {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px;
color: var(--text); font-size: 13px; outline: none; width: 100%; text-align: right;
font-variant-numeric: tabular-nums;
}
.price-input:focus { border-color: var(--iris); }
.price-input:disabled { opacity: 0.5; }
@media (max-width: 760px) {
.model-panes { grid-template-columns: 1fr; }
}
.field-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field-row-12 { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: start; }

Expand Down Expand Up @@ -1398,7 +1416,7 @@
.chip-x button:hover { color: var(--crit); }

@media (max-width: 560px) {
.field-row-2, .field-row-12, .field-row-13, .rate-grid { grid-template-columns: 1fr; }
.field-row-2, .field-row-12, .field-row-13 { grid-template-columns: 1fr; }
}

/* Settings → Prompts: list of prompt kinds, each a link into its detail/edit page */
Expand Down
27 changes: 14 additions & 13 deletions spire-ui/src/llmDialogs.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { join } from 'node:path';

/**
* The LLM dialogs are taller than a laptop window: the model dialog ran off screen with its buttons half
* hidden, and its price inputs sat at different heights (feedback, 2026-09-27). Layout cannot be measured
* in jsdom, so this holds the two rules that fix it and the markup that uses them.
* hidden, and its price inputs sat at different heights (feedback, 2026-09-27). The operator then chose a
* two-pane model dialog with narrow price inputs. Layout cannot be measured in jsdom, so this holds the
* rules and the markup that uses them.
*/
const SRC = join(__dirname);
const read = (file: string) => readFileSync(join(SRC, file), 'utf8');
Expand All @@ -16,19 +17,19 @@ it('caps a tall dialog at the window and keeps its buttons in view', () => {
expect(css).toMatch(/\.modal\.tall \{[^}]*max-height: calc\(100vh - 40px\)/);
expect(css).toMatch(/\.modal\.tall > \.modal-body \{[^}]*overflow-y: auto/);
expect(css).toMatch(/\.modal\.tall \.modal-actions \{[^}]*position: sticky/);
expect(read('components/SettingsLlmModelForm.tsx')).toContain('className="modal tall"');
expect(read('components/SettingsLlmModelForm.tsx')).toContain('className="modal tall model-dialog"');
expect(read('components/SettingsLlmProviders.tsx')).toContain('className="modal tall"');
});

it('lets the two price labels of a row share one height so the inputs line up', () => {
it('puts the model and its prices side by side, with narrow price inputs', () => {
const css = read('index.css');
expect(css).toMatch(/\.rate-field \{[^}]*grid-row: span 3;[^}]*grid-template-rows: subgrid/);
expect(css).toMatch(/\.rate-field > label\.field \{[^}]*display: contents/);
expect(css).toMatch(/\.rate-label \{[^}]*align-self: end/);
// On a narrow screen the price fields stack in one column, like the other field rows (review of PR #180).
expect(css).toMatch(/@media \(max-width: 560px\) \{\s*[^}]*\.rate-grid \{ grid-template-columns: 1fr; \}/);
const fields = read('components/SettingsLlmModelRateFields.tsx');
expect(fields).toContain('<div className="rate-grid">');
expect(fields).toContain('<div className="rate-field">');
expect(fields).toContain('<span className="rate-label">');
expect(css).toMatch(/\.modal\.model-dialog \{ max-width: 880px; \}/);
expect(css).toMatch(/\.model-panes \{[^}]*grid-template-columns: minmax\(0, 1fr\) 300px/);
expect(css).toMatch(/\.price-item \{[^}]*grid-template-columns: minmax\(0, 1fr\) 110px/);
// On a narrow screen the prices pane moves under the model.
expect(css).toMatch(/@media \(max-width: 760px\) \{\s*\.model-panes \{ grid-template-columns: 1fr; \}/);
const form = read('components/SettingsLlmModelForm.tsx');
expect(form).toContain('<div className="model-panes">');
expect(form).toContain('<section className="model-prices" aria-label="Prices">');
expect(read('components/SettingsLlmModelRateFields.tsx')).toContain('<div className="price-item">');
});
Loading