feat(FN-4616): complete Step 3 — add OpenRouter advanced settings UI
Ref: Runfusion/Fusion#325 Fusion-Task-Id: FN-4616 Fusion-Task-Lineage: ea5ea571-cc40-4ec0-86b4-2f003ac2834c
This commit is contained in:
@@ -206,6 +206,14 @@ function formatMemoryFileOptionLabel(file: MemoryFileInfo): string {
|
|||||||
return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS);
|
return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toCommaSeparatedInput(values?: string[]): string {
|
||||||
|
return values?.join(", ") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCommaSeparatedInput(value: string): string[] {
|
||||||
|
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||||
// Account group (scope-less items — independent of settings storage)
|
// Account group (scope-less items — independent of settings storage)
|
||||||
{ id: "__account_header", label: "Account", scope: undefined, isGroupHeader: true },
|
{ id: "__account_header", label: "Account", scope: undefined, isGroupHeader: true },
|
||||||
@@ -2584,6 +2592,202 @@ export function SettingsModal({
|
|||||||
flow and publishes them under the opencode-go provider in model pickers.
|
flow and publishes them under the opencode-go provider in model pickers.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>OpenRouter advanced</summary>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterAppAttributionReferer">OpenRouter HTTP-Referer</label>
|
||||||
|
<input
|
||||||
|
id="openrouterAppAttributionReferer"
|
||||||
|
className="input"
|
||||||
|
placeholder="https://runfusion.ai"
|
||||||
|
value={form.openrouterAppAttribution?.referer ?? ""}
|
||||||
|
onChange={(e) => setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterAppAttribution: {
|
||||||
|
...(f.openrouterAppAttribution || {}),
|
||||||
|
referer: e.target.value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<small>Leave empty to omit this header. Default: https://runfusion.ai.</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterAppAttributionTitle">OpenRouter X-Title</label>
|
||||||
|
<input
|
||||||
|
id="openrouterAppAttributionTitle"
|
||||||
|
className="input"
|
||||||
|
placeholder="Fusion"
|
||||||
|
value={form.openrouterAppAttribution?.title ?? ""}
|
||||||
|
onChange={(e) => setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterAppAttribution: {
|
||||||
|
...(f.openrouterAppAttribution || {}),
|
||||||
|
title: e.target.value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<small>Leave empty to omit this header. Default: Fusion.</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterModelFiltersSupportedParameters">OpenRouter supported_parameters filter</label>
|
||||||
|
<input
|
||||||
|
id="openrouterModelFiltersSupportedParameters"
|
||||||
|
className="input"
|
||||||
|
placeholder="tools, structured_outputs"
|
||||||
|
value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterModelFilters: {
|
||||||
|
...(f.openrouterModelFilters || {}),
|
||||||
|
supported_parameters: parsed.length > 0 ? parsed : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>Comma-separated values sent to OpenRouter model sync.</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterModelFiltersOutputModalities">OpenRouter output_modalities filter</label>
|
||||||
|
<input
|
||||||
|
id="openrouterModelFiltersOutputModalities"
|
||||||
|
className="input"
|
||||||
|
placeholder="text"
|
||||||
|
value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterModelFilters: {
|
||||||
|
...(f.openrouterModelFilters || {}),
|
||||||
|
output_modalities: parsed.length > 0 ? parsed : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>Comma-separated values sent to OpenRouter model sync.</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterProviderPreferencesOrder">OpenRouter routing order</label>
|
||||||
|
<input
|
||||||
|
id="openrouterProviderPreferencesOrder"
|
||||||
|
className="input"
|
||||||
|
placeholder="openai, anthropic"
|
||||||
|
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
...(f.openrouterProviderPreferences || {}),
|
||||||
|
order: parsed.length > 0 ? parsed : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterProviderPreferencesIgnore">OpenRouter routing ignore</label>
|
||||||
|
<input
|
||||||
|
id="openrouterProviderPreferencesIgnore"
|
||||||
|
className="input"
|
||||||
|
placeholder="provider-name"
|
||||||
|
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
...(f.openrouterProviderPreferences || {}),
|
||||||
|
ignore: parsed.length > 0 ? parsed : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterProviderPreferencesOnly">OpenRouter routing only</label>
|
||||||
|
<input
|
||||||
|
id="openrouterProviderPreferencesOnly"
|
||||||
|
className="input"
|
||||||
|
placeholder="provider-name"
|
||||||
|
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
...(f.openrouterProviderPreferences || {}),
|
||||||
|
only: parsed.length > 0 ? parsed : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">OpenRouter allow fallbacks</label>
|
||||||
|
<select
|
||||||
|
id="openrouterProviderPreferencesAllowFallbacks"
|
||||||
|
className="select"
|
||||||
|
value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
...(f.openrouterProviderPreferences || {}),
|
||||||
|
allow_fallbacks: value === "default" ? undefined : value === "allow",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="default">default</option>
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="deny">deny</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterProviderPreferencesSort">OpenRouter routing sort</label>
|
||||||
|
<select
|
||||||
|
id="openrouterProviderPreferencesSort"
|
||||||
|
className="select"
|
||||||
|
value={form.openrouterProviderPreferences?.sort ?? "default"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
...(f.openrouterProviderPreferences || {}),
|
||||||
|
sort: value === "default" ? undefined : value as "price" | "throughput" | "latency",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="default">default</option>
|
||||||
|
<option value="price">price</option>
|
||||||
|
<option value="throughput">throughput</option>
|
||||||
|
<option value="latency">latency</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="openrouterProviderPreferencesRequireParameters" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="openrouterProviderPreferencesRequireParameters"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.openrouterProviderPreferences?.require_parameters === true}
|
||||||
|
onChange={(e) => setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
...(f.openrouterProviderPreferences || {}),
|
||||||
|
require_parameters: e.target.checked,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
Require parameters
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1024,6 +1024,60 @@ describe("SettingsModal", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders and saves OpenRouter advanced settings", async () => {
|
||||||
|
mockFetchModels.mockResolvedValue({
|
||||||
|
models: MODEL_FIXTURE,
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Models" }));
|
||||||
|
await userEvent.click(screen.getByText("OpenRouter advanced"));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("OpenRouter HTTP-Referer")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("OpenRouter X-Title")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText("OpenRouter HTTP-Referer"), "https://example.app");
|
||||||
|
await userEvent.type(screen.getByLabelText("OpenRouter X-Title"), "Example App");
|
||||||
|
fireEvent.change(screen.getByLabelText("OpenRouter supported_parameters filter"), {
|
||||||
|
target: { value: "tools, structured_outputs" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("OpenRouter output_modalities filter"), { target: { value: "text" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("OpenRouter routing order"), { target: { value: "openai, anthropic" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("OpenRouter routing ignore"), { target: { value: "provider-x" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("OpenRouter routing only"), { target: { value: "provider-y" } });
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText("OpenRouter allow fallbacks"), "deny");
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText("OpenRouter routing sort"), "latency");
|
||||||
|
await userEvent.click(screen.getByLabelText("Require parameters"));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
openrouterAppAttribution: { referer: "https://example.app", title: "Example App" },
|
||||||
|
openrouterModelFilters: {
|
||||||
|
supported_parameters: ["tools", "structured_outputs"],
|
||||||
|
output_modalities: ["text"],
|
||||||
|
},
|
||||||
|
openrouterProviderPreferences: {
|
||||||
|
order: ["openai", "anthropic"],
|
||||||
|
ignore: ["provider-x"],
|
||||||
|
only: ["provider-y"],
|
||||||
|
allow_fallbacks: false,
|
||||||
|
sort: "latency",
|
||||||
|
require_parameters: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a project-scoped default model lane", async () => {
|
it("renders a project-scoped default model lane", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({
|
mockFetchSettings.mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
|||||||
Reference in New Issue
Block a user