Model Profiles Plugin
The Model Profiles plugin lets authenticated users define their own named LLM profiles (model id, base URL, API key, thinking options) and assign them to specific agent roles — overriding which model each role uses for that user's turns. Deployers control whether users may bring their own API keys and which endpoints are allowed.
Using it
On the web
When Thinking Modes is also loaded, open Settings → Models (the root
pane Thinking Modes declares). Its Connections section is this plugin's
element:
- From your organization — the deployment model and the connections the administrator configured, read-only, each model with a show in picker checkbox. Unticking one takes it out of your picker only; a conversation already on it, a pin or a light model naming it still works. (In community these are yours too, marked "From your configuration file".)
- Yours — the endpoints you added and the ChatGPT plan a sign-in connects: the same checkboxes (a new plan shows its newest models, older ones listed but unticked), the light model that does its chats' housekeeping, Test and Remove for an endpoint, Disconnect for a sign-in, the privacy class in community only, and a rename when the organization has since taken the connection's name.
- Add connection offers only the doors open to you: Sign in with ChatGPT (the
openai_codexsign-in,models:connect_personal), API key (a provider from the catalog, or any URL) and Local server (Ollama, LM Studio, or a URL) — the last two needmodel_profiles:manageand stay insideallowed_base_urls/allow_custom_api_keys.
The Role map in the same panel, with Custom selected, is where you
assign a connection to a role.
This plugin's settings_widget is conditional: it claims an Extensions
row of its own only when Thinking Modes is not among loaded, enabled
siblings — with it, Settings → Models is the one surface for both. The UI bundle (<x-model-profiles-connections>) is always
registered so Thinking Modes can mount it. The HTTP API never depends on who
is showing the UI.
Without Thinking Modes, Settings → Extensions → Model Profiles is the
standalone mount of the same element: title and description of its own, then
the same CRUD. There is no mode selector; the table lists your assignments
only — an unassigned role reads not set, because working out what it would
inherit is that plugin's job.
Assignments made in the Custom role map are the same per-user assignments this
plugin has always stored — they are what the
Thinking Modes plugin surfaces as the implicit Custom
mode.
Roles you can assign: main_agent, a catch-all default, subagent,
agentic_developer, agentic_memory_store, context_summarization,
explore, goal, web_retrieve, web_search, auto_title, translation
and voice_mediator.
In the terminal UI
Settings → Model Profiles shows both halves in one pane: the role map
first, then the profile list with its Add / Edit / Test / Del buttons. The map
is the web's, drawn for a terminal — a Mode dropdown at the top chooses
which mode it describes, every role gets its name in words, one line saying
what it does and the resolved connection → model-id with the same badge, and
the rows turn into dropdowns on Custom. Without the
Thinking Modes plugin there is no mode dropdown and the
map lists your own assignments, unassigned roles reading not set.
Everywhere
Deployers can define read-only admin profiles in the main models.profiles
block. Users may adopt those by supplying their own key (the POST /adopt/{name}
route and the terminal pane; the web's Connections section lists them among
the organization's connections instead).
Overrides apply only to authenticated users, and only while their turn is running.
Configuration
plugins:
- module: codumentor.plugins.model_profiles
class: ModelProfilesPlugin
priority: 5
args:
enabled: true
allow_custom_api_keys: true
max_profiles_per_user: 20
# allowed_base_urls: ["https://openrouter.ai/api/v1"]
Not enabled in the default shipped config; uncomment or add the snippet above to turn it on. Managing profiles — and typing in an endpoint on the web — requires the model_profiles:manage permission (power_user and admin have it by default) — without it, the role map is still shown, read-only, and the web's Connections section still lists every connection, lets the user hide models from their own picker and edit the ChatGPT plan they signed in with (models:connect_personal).
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether the plugin is active. |
allow_custom_api_keys | bool | true | Allow users to store their own API keys in profiles. When false, users can only use deployer-defined profiles. |
allowed_base_urls | list | unset | Allow-list of permitted endpoint base URLs. Omit or leave empty to allow any. |
max_profiles_per_user | int | 20 | Maximum profiles per user. |
data_dir | string | agent storage dir, else ./data | Where profile data is stored. |
previous_jwt_secrets | list | unset | Older JWT secrets, so profiles encrypted under a rotated key can still be decrypted. |
Connections
A user's models come from connections (thinking-modes-ux-rethink §4.2):
their own endpoints (an API key or a local server) and the subscription a
sign-in implies — the ChatGPT connection derived from an
openai_codex account, found by a duck-typed sibling probe
(derived_connections), one per account, never stored twice. Each model is
referenced as <connection>/<model> and listed under Yours in the model
picker, after the organization's connections, unless the user hid it ("show
in picker").
- Profiles are one-model connections. Storage schema v3 copied every profile into the
connectionstable (provider: openai-compatible, the key still encrypted); the profile routes and the TUI pane edit the connection's first model, and a profile's bare name still names it. The v2profilestable and theassignmentsare kept for one minor release of rollback; a deleted connection's v2 row is removed with it, and a replaced key is stripped from it. - One id namespace with the organization. A connection id (or an
<id>/<model>) the organization already has — a connection id or a legacy profile name — is refused at creation (409); one the organization adds later flags the user's connectionneeds_rename, and it is left out of the picker and the resolver until renamed. A user's profile never takes a name the organization uses, bare or<connection>/<model>, and an organization profile'sfallback_profileresolves among the organization's profiles only. - A sign-in's connections keep their ids. One per connected account (
chatgpt,chatgpt-2…). Each one's row is stored the first time it is seen — only its id and source (oauth:<provider>#<account>) until the user sets a preference or a listing is read — so the id stays with its account when another account or endpoint comes or goes. A connection or profile the user creates or renames cannot take such an id (409, every door), and a listing is saved on the row with its source, never by id, so an answer that arrives after a rename or a disconnect lands on no other row. - Keys. A key comes only from
api_key(policed byallow_custom_api_keys, encrypted at rest).settingsholds model settings only —extra_body,thinking_enabled,context_window,reasoning, sampling and timeouts; a key,base_url,providerorfallback_profilethere is refused (400). The mask a card shows (sk-…abcd) is accepted back as "keep the stored key", and so is a key that no longer decrypts (after thejwt_secretchanged withoutprevious_jwt_secrets): it reads back masked and later edits leave its ciphertext in place. - Privacy. In enterprise every user connection is
personal; in community the user may declare its class, else it is derived from the host. A subscription is alwayspersonal.
Routes, under /ui/plugins/model_profiles. Who may call them: any
signed-in user lists and trims; a subscription's preferences need
models:connect_personal (the user role has it) or model_profiles:manage;
creating, editing, testing or removing an endpoint needs
model_profiles:manage (403 Permission denied: model_profiles:manage). The
profile, assignment and adopt routes keep model_profiles:manage.
| Route | Needs | What |
|---|---|---|
GET /connections | signed in | organization: the organization's connections as read-only cards (models with key and the caller's hidden, the light model, privacy, scope, provider_label); connections: the user's own as cards (models with hidden, light model, privacy, needs_rename, listing source, editable, a subscription's sign_in account); doors: each way to add one, open (then with allowed_base_urls, the form's hint) or with its reason; providers the API key / Local server doors offer (inside allowed_base_urls) |
PATCH /organization-connections/{id} | signed in | hidden_models: the model keys the caller hides from their own picker (a legacy profile group's keys are profile names); 404 for an id with no card |
POST /connections | model_profiles:manage | add an endpoint: id, base_url, models (ids without whitespace, quotes, backticks, angle brackets or control characters), optional api_key, title, light_model, privacy (community), settings (model settings only, see Keys), hidden_models |
PATCH /connections/{id} | by kind | preferences: hidden_models, light_model, privacy, new_id; an endpoint's title, base_url, api_key, models |
DELETE /connections/{id} | model_profiles:manage | remove an endpoint (a sign-in's connection goes with the account: disconnect it through the OAuth plugin) |
POST /connections/{id}/test | model_profiles:manage | one request against an endpoint |
POST /connections/{id}/refresh | by kind | re-read a subscription's model listing |
The organization's cards and the "show in picker" choice on them are not
personal connections: they stay listed and editable whatever the policy below
says. The choice is applied in the picker only, never in resolution.
Personal connections off. When model_selection.personal_connections is
false, or the caller's access profile says personal_connections: false,
the user's connections are withheld from every surface this plugin feeds: the
override dict a turn installs carries none of their models (only their titles,
so the resolver can say what it fell back from), the picker has no "Yours"
group, GET /connections answers connections: [], providers: [], every
door closed with the reason and
personal_connections: {"allowed": false, "reason": …}, and every other
route on one of the user's own connections — and POST/PUT /profiles/{name}, POST /adopt/{name},
POST /profiles/{name}/test — answers 403 with the reason. It is decided
on every call, per turn for a turn, so a profile that tightens is in force at
the next turn. Nothing is deleted. See
Access profiles.
Notes
- Encryption at rest: API keys are encrypted with a key derived from
auth.jwt_secret(see User Secrets). Without a configured secret, keys fall back to plaintext. - A test action in the settings UI sends a tiny completion to verify a profile's endpoint, model, and key before you rely on it.
- The Role map's fourth column is the resolved
role → connection → model-idchain, and its badge says why —assigned,inherited,serverorbuilt-in model. It is the fastest way to check that an assignment did what you meant. - This is the per-user counterpart to the deployer-level
models.profilesblock in the main configuration.