Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ product overview.

## Unreleased

- Supply new installations with Astra, Sol, Terra, Fable, Opus and Sonnet (`:sonet`) shortcuts
using the newest supported stable family versions. Preserve customized lists and thread pins.

- Let organization admins use another requester's Slack menu buttons and question forms. Menu
views open under the clicking admin's own identity; question answers continue the original
requester's session and record which admin responded. Current access, membership and stale-form
Expand Down
8 changes: 8 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,14 @@ control-plane status.

## Gateway model shortcuts

New installations inherit `:astra`, `:sol`, `:terra`, `:fable`, `:opus` and `:sonet` from the
engine model catalogs. Each resolves to the numerically newest stable version of its family,
including Sonnet 5.5 and GPT-6.1 Sol when offered by the catalog. Live Codex discovery can advance
these defaults; Claude uses the curated supported catalog shipped with the gateway. Missing
families are omitted instead of guessed. A stored shortcut map, including an intentionally empty
map, is preserved exactly. Saving unrelated settings keeps inherited defaults; editing the shortcut
list makes it an explicit set of pins. Existing thread pins never follow catalog updates.

Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent :astra task` pins that engine and model to the current thread and runs the task; `@agent :astra` switches the thread without starting an agent turn. Slack's automatically inserted space after the mention is accepted. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply.

## Public documentation website
Expand Down
14 changes: 14 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,20 @@ Record blocked fixtures and unexecuted gates explicitly for both engines.

## Model shortcuts acceptance (2026-10-06)

- [x] Automated: fresh settings without `modelShortcuts` inherit the six catalog families. Numeric
ordering selects 6.10 over 6.9 and Sonnet 5.10 over 5.5 independent of catalog order;
aliases/previews and missing families never invent targets. Newer catalogs update inherited
defaults without mutating settings; explicit custom/empty maps survive and unrelated saves
keep inheritance (`test/model-shortcuts.test.js`).
- [ ] Live Claude/Codex: on a disposable installation without a saved shortcut map, inspect Settings
and send `@agent :sonet Reply exactly DEFAULT_SONNET_OK` and `@agent :sol Reply exactly
DEFAULT_SOL_OK` as Contact in the corresponding private Auto fixtures. Pass: the supported
latest family IDs are selected and pinned, exact sentinels return on the requested engine,
and normal followups retain those pins. Save unrelated settings; inherited defaults stay
inherited. Customize one mapping, then empty the list; reload/upgrade must preserve each
explicit choice. Use an isolated catalog fixture to introduce newer family versions; only
inherited mappings advance, never existing thread pins or a customized list.

- [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings.
- [x] Browser: `test/model-shortcuts-browser.test.js` ran in Chromium against a disposable admin API and store. Added a Codex shortcut from the current catalog, saved and reloaded it, repointed its model and engine, and removed it; no page errors.
- [x] Pipeline: `test/model-shortcut-pipeline.test.js` ran the real Slack pipeline with spawned Claude/Codex fixture CLIs. Verified Slack's inserted space, model flags on new/resumed turns, a Codex→Claude switch, stable pins after repointing a shortcut, stripped prefixes on runtime-card retries, and refusal before pin changes for runtime permissions, disabled engines and a dedicated Codex login. These are integration fixtures, not live provider acceptance.
Expand Down
14 changes: 14 additions & 0 deletions documentation/src/content/docs/configuration/model-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ Optional provider harnesses must be explicitly enabled and authenticated. At lea

An empty gateway model setting lets the CLI use its own default. An explicit gateway model helps avoid changes in the operator's interactive terminal model choice affecting gateway runs.

## Default model shortcuts

New installations include `:astra`, `:sol`, `:terra`, `:fable`, `:opus`, and `:sonet` (Sonnet).
Use `@agent :sonet your task` in Slack to select and pin the current supported Sonnet model.
Before an administrator saves a custom shortcut list, targets follow the newest stable version
of each family in the gateway's model catalog. Codex uses its authenticated CLI catalog, with a
bundled fallback; Claude uses the curated catalog in the installed gateway version. Updating the
CLI or gateway is therefore still necessary when a provider exposes a model they do not yet list.
Unavailable families are omitted rather than assigned a guessed model ID.

Edit shortcuts under **Settings → Access & security → Model shortcuts**. A saved list uses those
exact mappings until edited again; an empty list disables shortcuts. Existing customized lists and
thread pins survive updates. Saving unrelated settings does not turn inherited defaults into pins.

## Conversation and thread overrides

Set a conversation's **Engine**, **Model**, and **Effort** under **Conversations → Runtime**. On Slack, use `/model` to select a channel-wide or **just this thread** runtime. A thread-scoped choice does not change the default for other threads.
Expand Down
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,7 @@ <h4>Runtime changes</h4>
</div>
<div class="setcard">
<h4>Model shortcuts</h4>
<p class="desc">Mention the bot, then type a colon and a name after Slack's inserted space, for example <code>@agent :astra review this</code>. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply.</p>
<p class="desc">Mention the bot, then type a colon and a name after Slack's inserted space, for example <code>@agent :astra review this</code>. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply. New installations include Astra, Sol, Terra, Fable, Opus and <code>:sonet</code> for Sonnet. Until this list is customized, targets follow the newest stable version in each family from the supported model catalog. Saving an edited list keeps those exact choices.</p>
<div id="model-shortcuts-editor"></div>
<button type="button" class="clear-tok" id="add-model-shortcut" disabled>Add shortcut</button>
</div>
Expand Down
38 changes: 37 additions & 1 deletion src/config/model-shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,43 @@
export const MODEL_SHORTCUT_NAME_RE = /^[a-z][a-z0-9_-]{0,23}$/;
export const MAX_MODEL_SHORTCUTS = 32;

export function modelShortcutsFromSettings(settings) {
// Families are stable shortcut names; exact versions come only from the engine catalogs.
const DEFAULT_SHORTCUT_FAMILIES = [
["astra", "codex", /^gpt-(\d+(?:\.\d+)*)-astra$/],
["sol", "codex", /^gpt-(\d+(?:\.\d+)*)-sol$/],
["terra", "codex", /^gpt-(\d+(?:\.\d+)*)-terra$/],
["fable", "claude", /^claude-fable-(\d+(?:-\d+)*)$/],
["opus", "claude", /^claude-opus-(\d+(?:-\d+)*)$/],
["sonet", "claude", /^claude-sonnet-(\d+(?:-\d+)*)$/],
];

function compareVersions(left, right) {
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const delta = (left[index] || 0) - (right[index] || 0);
if (delta) return delta;
}
return 0;
}

export function defaultModelShortcuts(catalogs = {}) {
const shortcuts = {};
for (const [name, engine, pattern] of DEFAULT_SHORTCUT_FAMILIES) {
let latest = null;
for (const entry of catalogs[engine] || []) {
const match = pattern.exec(String(entry?.value || ""));
if (!match || /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/.test(entry.value)) continue; // Ignore aliases, preview IDs and unrelated model families.
const version = match[1].split(/[.-]/).map(Number);
if (!latest || compareVersions(version, latest.version) > 0) latest = { model: entry.value, version };
}
if (latest) shortcuts[name] = { engine, model: latest.model };
}
return shortcuts;
}

export function modelShortcutsFromSettings(settings, catalogs = {}) {
// A missing key inherits current catalog defaults. A saved list, including {}, is owned by
// the administrator: upgrades must never overwrite custom pins or resurrect deleted names.
if (!Object.hasOwn(settings || {}, "modelShortcuts")) return defaultModelShortcuts(catalogs);
const raw = settings?.modelShortcuts;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return Object.fromEntries(Object.entries(raw).filter(([name, target]) =>
Expand Down
4 changes: 2 additions & 2 deletions src/config/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import path from "node:path";
import { settingsFile } from "./paths.js";
import { writeSecretFile } from "./harden.js";
import { getDb } from "../db/index.js";
import { ENGINE_IDS, adapterOr } from "../engines/registry.js";
import { ENGINE_IDS, adapterOr, modelsForEngine } from "../engines/registry.js";
// Value-only import (the default endpoint constant). qwen.js reads settings LAZILY, so this
// direction carries no cycle.
import { QWEN_PROVIDERS, qwenProvider } from "../engines/qwen.js";
Expand Down Expand Up @@ -335,7 +335,7 @@ export function getDefaultModel(engine) {
}

export function getModelShortcuts() {
return modelShortcutsFromSettings(getSettings());
return modelShortcutsFromSettings(getSettings(), { claude: modelsForEngine("claude"), codex: modelsForEngine("codex") });
}

// Who may use Slack's /model wizard in a CHANNEL (both channel-wide and thread-scoped picks).
Expand Down
19 changes: 18 additions & 1 deletion test/model-shortcut-pipeline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ process.env.PATH = `${fileURLToPath(new URL("./fixtures", import.meta.url))}${pa
process.env.SESSION_KEEPALIVE = "0";
process.env.PROGRESS_VIEW = "shimmer";
const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/config/store.js");
const { saveSettings } = await import("../src/config/settings.js");
const { saveSettings, getModelShortcuts, getDefaultModel, getEngine } = await import("../src/config/settings.js");
const { getThreadEngine, getThreadModel, getThreadEffort, setThreadEffort } = await import("../src/gateway/thread-engine.js");
const { processMessageEvent } = await import("../src/slack/message-pipeline.js");
const { stripMentions } = await import("../src/slack/message-normalize.js");
Expand All @@ -37,6 +37,23 @@ const client = {
},
};

test("fresh-install defaults select supported Sonnet and Sol without changing gateway defaults", async () => {
await setUser(USER, { name: "Shortcut User", approved: true, isAdmin: false });
const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true });
await saveChannelMeta(entry.slug, { channelId: CHANNEL, name: entry.name, type: "im", isDM: true, template: "custom", engine: "claude" });
const engine = getEngine();
const defaults = {claude: getDefaultModel("claude"), codex: getDefaultModel("codex")};
const shortcuts = getModelShortcuts();
for (const [index, name] of ["sonet", "sol"].entries()) {
const ts = `122.00${index}`;
await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}> :${name} short task`, ts }, client, { botUserId: BOT });
assert.equal(await getThreadModel(entry.slug, ts), shortcuts[name].model);
assert.equal(await getThreadEngine(entry.slug, ts), shortcuts[name].engine);
}
assert.equal(getEngine(), engine);
assert.deepEqual({claude: getDefaultModel("claude"), codex: getDefaultModel("codex")}, defaults);
});

test("Slack's inserted space before :astra switches a DM thread and clears an old effort pin", async () => {
await setUser(USER, { name: "Shortcut User", approved: true, isAdmin: false });
const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true });
Expand Down
13 changes: 12 additions & 1 deletion test/model-shortcuts-browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ensureTestEnv();
test("settings creates, repoints and removes model shortcuts through the live catalog", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => {
const { chromium } = await import(process.env.CG_BROWSER_MODULE);
const { createAdminRouter } = await import("../src/web/routes/admin.js");
const { getModelShortcuts } = await import("../src/config/settings.js");
const { getModelShortcuts, saveSettings, getSettings } = await import("../src/config/settings.js");
const app = express();
app.use(express.json());
app.get("/api/skills", (_req, res) => res.json({ skills: [] }));
Expand All @@ -35,6 +35,17 @@ test("settings creates, repoints and removes model shortcuts through the live ca
assert.equal((await response).status(), 200);
await page.waitForFunction(() => globalThis.document.querySelector("#settings-dirty-msg")?.textContent === "All changes saved");
};
assert.equal(await page.locator(".model-shortcut-row").count(), 6, "fresh settings render all inherited families");
assert.equal(await page.locator('.shortcut-name').evaluateAll(nodes => nodes.some(n => n.value === "sonet")), true);
await page.locator("#set-keepalive").fill("15m");
await persist();
assert.equal(Object.hasOwn(getSettings(), "modelShortcuts"), false, "an unrelated API save keeps defaults inherited");
await page.reload();
await page.waitForFunction(() => globalThis.document.querySelector("#add-model-shortcut")?.disabled === false);
assert.equal(await page.locator(".model-shortcut-row").count(), 6);
saveSettings({ modelShortcuts: {} });
await page.reload();
await page.waitForFunction(() => globalThis.document.querySelector("#add-model-shortcut")?.disabled === false);
await page.locator("#add-model-shortcut").click();
const row = page.locator(".model-shortcut-row").first();
await row.locator(".shortcut-name").fill("astra");
Expand Down
40 changes: 38 additions & 2 deletions test/model-shortcuts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import express from "express";
import { ensureTestEnv } from "./helpers.js";

ensureTestEnv();
const { parseModelShortcut } = await import("../src/config/model-shortcuts.js");
const { getModelShortcuts, settingsForApi, saveSettings } = await import("../src/config/settings.js");
const { parseModelShortcut, defaultModelShortcuts, modelShortcutsFromSettings } = await import("../src/config/model-shortcuts.js");
const { getModelShortcuts, settingsForApi, saveSettings, getSettings } = await import("../src/config/settings.js");
const { createAdminRouter } = await import("../src/web/routes/admin.js");

const app = express();
Expand All @@ -24,6 +24,42 @@ async function put(modelShortcuts, expectedStatus = 200) {
return response.json();
}

test("fresh installs inherit supported family defaults without persisting version pins", () => {
const defaults = getModelShortcuts();
assert.deepEqual(Object.keys(defaults).sort(), ["astra", "fable", "opus", "sol", "sonet", "terra"]);
assert.equal(defaults.sonet.engine, "claude");
assert.match(defaults.sonet.model, /^claude-sonnet-/);
assert.equal(defaults.sol.engine, "codex");
assert.match(defaults.sol.model, /^gpt-[\d.]+-sol$/);
saveSettings({ sessionKeepalive: "10" });
assert.deepEqual(getModelShortcuts(), defaults, "unrelated saves retain inheritance");
assert.deepEqual(settingsForApi().modelShortcuts, defaults);
assert.equal(Object.hasOwn(getSettings(), "modelShortcuts"), false);
});

test("catalog defaults choose the newest stable family numerically and omit absent families", () => {
const catalogs = {
claude: ["claude-sonnet-5-9", "claude-sonnet-5-10", "claude-sonnet-6-preview", "claude-sonnet-9-20261023", "claude-sonnet-9-2026-10-23", "sonnet", "claude-opus-5-5", "claude-haiku-5-5"].map(value => ({ value })),
codex: ["gpt-6.9-sol", "gpt-6.10-sol", "gpt-6.10-sol-preview", "gpt-7-sol", "gpt-6-astra", "codex"].map(value => ({ value })),
};
const initial = defaultModelShortcuts(catalogs);
assert.equal(initial.sonet.model, "claude-sonnet-5-10");
assert.equal(initial.sol.model, "gpt-7-sol");
assert.equal(defaultModelShortcuts({codex: catalogs.codex.slice(0, 2)}).sol.model, "gpt-6.10-sol");
assert.equal(initial.fable, undefined);
assert.equal(initial.terra, undefined);
const settings = {};
assert.deepEqual(modelShortcutsFromSettings(settings, catalogs), initial);
catalogs.claude.push({ value: "claude-sonnet-6" });
assert.equal(modelShortcutsFromSettings(settings, catalogs).sonet.model, "claude-sonnet-6");
assert.deepEqual(settings, {}, "resolution never freezes a version into settings");
assert.deepEqual(modelShortcutsFromSettings({modelShortcuts: {}}, catalogs), {});
const custom = {sonet: {engine: "claude", model: "claude-sonnet-5-5"}};
assert.deepEqual(modelShortcutsFromSettings({modelShortcuts: custom}, catalogs), custom);
assert.deepEqual(modelShortcutsFromSettings({modelShortcuts: null}, catalogs), {});
assert.deepEqual(defaultModelShortcuts(), {});
});

test("mention suffix parses a shortcut and preserves the task", () => {
assert.deepEqual(parseModelShortcut(":astra build this"), { name: "astra", task: "build this" });
assert.deepEqual(parseModelShortcut(": FABLE"), { name: "fable", task: "" });
Expand Down
Loading