blob: 3e72754ff6a6ae8a5ebb682105f30b1555874964 [file] [edit]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// The settings-page UI for the LLM gateway: configure providers (pick a
// protocol, fill in its credential fields, list the models to surface) and pick
// the default model. Rendered inline on the settings page via the custom
// `render` hook on the gateway's settings (see index.ts). The provider form is
// data-driven off each protocol's declared `credentialFields`, so the gateway
// never hard-codes any backend's login form.
import './settings.scss';
import m from 'mithril';
import {uuidv4} from '../../base/uuid';
import type {Setting} from '../../public/settings';
import {Button, ButtonVariant} from '../../widgets/button';
import {Combobox} from '../../widgets/combobox';
import {Select} from '../../widgets/select';
import {TextInput} from '../../widgets/text_input';
import type {LlmGateway} from './gateway';
import {
MODEL_ROLES,
type Model,
type ModelRole,
type Provider,
type ProvidersSetting,
} from './config';
import {Checkbox} from '../../widgets/checkbox';
import {assertIsInstance} from '../../base/assert';
import {QuerySlot} from '../../base/query_slot';
type ModelFetchResult =
| {readonly status: 'done'; readonly models: readonly string[]}
// 'unsupported' = the protocol can't list models; fall back to free text with
// no suggestions. 'error' = it tried and failed (bad key, unreachable, ...).
| {readonly status: 'unsupported'}
| {readonly status: 'error'; readonly message: string};
// Returns the current fetch state for a provider's available models, kicking off
// a background fetch (and a redraw on completion) if none is in flight for the
// current key.
async function fetchAvailableModels(
gateway: LlmGateway,
provider: Provider,
signal?: AbortSignal,
): Promise<ModelFetchResult> {
try {
const models = await gateway.listAvailableModels(provider.id, signal);
if (models === undefined) {
return {status: 'unsupported'};
} else {
return {status: 'done', models: models.map((mm) => mm.name)};
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return {status: 'error', message};
}
}
// Read-modify-write a provider list immutably. The setting persists on set().
function update(
setting: Setting<ProvidersSetting>,
fn: (providers: Provider[]) => Provider[],
): void {
const next = fn([...setting.get()]);
setting.set(next);
}
function patchProvider(
setting: Setting<ProvidersSetting>,
id: string,
fn: (p: Provider) => Provider,
): void {
update(setting, (providers) =>
providers.map((p) => (p.id === id ? fn(p) : p)),
);
}
// --- The providers editor ----------------------------------------------------
interface LlmSettingsAttrs {
readonly gateway: LlmGateway;
readonly setting: Setting<ProvidersSetting>;
}
export function LlmSettings(): m.Component<LlmSettingsAttrs> {
return {
view({attrs}: m.Vnode<LlmSettingsAttrs>) {
const {gateway, setting} = attrs;
const protocols = gateway.listProtocols();
const providers = setting.get();
return m(
'.pf-llm-settings',
providers.length === 0
? m('.pf-llm-settings__empty', 'No providers configured yet.')
: providers.map((provider) =>
m(ProviderEditor, {gateway, setting, provider}),
),
protocols.length === 0
? m(
'.pf-llm-settings__empty',
'No LLM protocols are registered. Enable a protocol plugin (e.g. ' +
'dev.perfetto.LlmProtocolGemini) to add providers.',
)
: m(Button, {
label: 'Add provider',
icon: 'add',
variant: ButtonVariant.Filled,
onclick: () => {
// New providers default to the first registered protocol.
const protocol = protocols[0];
update(setting, (ps) => [
...ps,
{
id: uuidv4(),
protocol: protocol.id,
credentials: {},
models: [],
},
]);
},
}),
);
},
};
}
interface ProviderEditorAttrs {
readonly gateway: LlmGateway;
readonly setting: Setting<ProvidersSetting>;
readonly provider: Provider;
}
function ProviderEditor(): m.Component<ProviderEditorAttrs> {
const modelsSlot = new QuerySlot<ModelFetchResult>();
return {
view({attrs}: m.Vnode<ProviderEditorAttrs>) {
const {gateway, setting, provider} = attrs;
const protocol = gateway.getProtocol(provider.protocol);
const availableModels = modelsSlot.use({
key: [provider.protocol, provider.credentials],
// TODO: wire the QuerySlot's CancellationSignal through to
// fetchAvailableModels' AbortSignal so in-flight fetches are actually
// cancelled when the key changes.
queryFn: () => fetchAvailableModels(gateway, provider),
});
let status: m.Children = null;
let modelsToPick: readonly string[] = [];
const models = availableModels.data;
if (!models) {
status = m('span.pf-llm-models-status', 'fetching available models…');
} else {
if (models.status === 'done') {
status = m(
'span.pf-llm-models-status',
`${models.models.length} models available`,
);
modelsToPick = models.models;
} else if (models.status === 'error') {
status = m(
'span.pf-llm-models-status.pf-llm-models-status--error',
{title: models.message},
'couldn’t fetch models — type the name manually',
);
} else if (models.status === 'unsupported') {
status = m(
'span.pf-llm-models-status',
`This protocol doesn't support listing models`,
);
}
}
const modelStatusRow = m(
'span.pf-llm-models-status-row',
status,
models?.status !== 'unsupported' &&
m(Button, {
icon: 'refresh',
title: 'Refresh available models',
onclick: () => {
modelsSlot.invalidate();
},
}),
);
return m(
'.pf-llm-provider',
m(
'.pf-llm-provider__header',
m(TextInput, {
value: provider.label,
placeholder: 'Provider label',
oninput: (e: Event) => {
assertIsInstance(e.target, HTMLInputElement);
const value = e.target.value;
patchProvider(setting, provider.id, (p) => ({
...p,
label: value,
}));
},
}),
m(
Select,
{
value: provider.protocol,
onchange: (e: Event) =>
patchProvider(setting, provider.id, (p) => ({
...p,
protocol: (e.target as HTMLSelectElement).value,
// Credentials are protocol-specific; clear on protocol change.
credentials: {},
})),
},
gateway
.listProtocols()
.map((pr) => m('option', {value: pr.id}, pr.label)),
),
m(Button, {
icon: 'delete',
title: 'Remove provider',
onclick: () =>
update(setting, (ps) => ps.filter((p) => p.id !== provider.id)),
}),
),
// Credential fields, driven by the protocol's declaration.
protocol === undefined
? m(
'.pf-llm-provider__warning',
`Protocol "${provider.protocol}" is not registered. Enable its ` +
'plugin or pick another protocol.',
)
: protocol.credentialFields.map((field) =>
m('.pf-llm-field', [
m('label.pf-llm-field__label', field.label),
m(TextInput, {
value: provider.credentials[field.key] ?? '',
type: field.secret ? 'password' : 'text',
placeholder: field.placeholder ?? '',
oninput: (e: Event) =>
patchProvider(setting, provider.id, (p) => ({
...p,
credentials: {
...p.credentials,
[field.key]: (e.target as HTMLInputElement).value,
},
})),
}),
]),
),
// The model catalog.
m('.pf-llm-provider__models-label', 'Models', modelStatusRow),
provider.models.map((model) =>
renderModelRow(setting, provider, model, modelsToPick),
),
m(Button, {
label: 'Add model',
icon: 'add',
onclick: () =>
patchProvider(setting, provider.id, (p) => ({
...p,
models: [
...p.models,
{
id: uuidv4(),
modelName: '',
roles: ['agentic', 'flash'],
},
],
})),
}),
);
},
};
}
function renderModelRow(
setting: Setting<ProvidersSetting>,
provider: Provider,
model: Model,
availableModels: readonly string[],
): m.Children {
const patchModel = (fn: (m: Model) => Model) =>
patchProvider(setting, provider.id, (p) => ({
...p,
models: p.models.map((m) => (m.id === model.id ? fn(m) : m)),
}));
const toggleRole = (role: ModelRole) =>
patchModel((mdl) => ({
...mdl,
roles: mdl.roles.includes(role)
? mdl.roles.filter((r) => r !== role)
: [...mdl.roles, role],
}));
return m(
'.pf-llm-model',
m(TextInput, {
value: model.label,
placeholder: 'Display label',
oninput: (e: Event) =>
patchModel((mdl) => ({
...mdl,
label: (e.target as HTMLInputElement).value,
})),
}),
// The backend model name. A combobox: suggestions come from the provider's
// listModels() when available, but the input stays free-text so a name the
// backend didn't advertise (or any backend that can't list) still works.
m(Combobox, {
className: 'pf-llm-model__name',
value: model.modelName,
placeholder: 'Backend model name, e.g. gemini-2.5-flash',
suggestions: availableModels,
onChange: (value: string) =>
patchModel((mdl) => ({...mdl, modelName: value})),
}),
m(
'.pf-llm-model__roles',
MODEL_ROLES.map((role) =>
m(
'label.pf-llm-model__role',
m(Checkbox, {
label: role,
checked: model.roles.includes(role),
onchange: () => toggleRole(role),
}),
),
),
),
m(Button, {
icon: 'delete',
title: 'Remove model',
onclick: () =>
patchProvider(setting, provider.id, (p) => ({
...p,
models: p.models.filter((m) => m.id !== model.id),
})),
}),
);
}