| // 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), |
| })), |
| }), |
| ); |
| } |