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. */}
{(
@@ -132,17 +137,16 @@ interface ModelRateFieldsProps {
export default function ModelRateFields({ rates, onChange, notBilled, onNotBilled }: ModelRateFieldsProps) {
return (
<>
-
+
{RATE_TYPES.map((type) => (
onChange(type, v)}
notBilled={!!notBilled[type]} onNotBilled={(v) => onNotBilled(type, v)} />
))}
- 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. * 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.
>
);
diff --git a/spire-ui/src/index.css b/spire-ui/src/index.css
index 04805c01..ca1dfe9b 100644
--- a/spire-ui/src/index.css
+++ b/spire-ui/src/index.css
@@ -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; }
@@ -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 */
diff --git a/spire-ui/src/llmDialogs.contract.test.ts b/spire-ui/src/llmDialogs.contract.test.ts
index 5af5972b..6478ca95 100644
--- a/spire-ui/src/llmDialogs.contract.test.ts
+++ b/spire-ui/src/llmDialogs.contract.test.ts
@@ -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');
@@ -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('
');
- expect(fields).toContain('
');
- expect(fields).toContain('
');
+ 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('');
+ expect(form).toContain('
');
+ expect(read('components/SettingsLlmModelRateFields.tsx')).toContain('');
});