feat(project): per-project new-chat defaults (agent, workspace, approval, thinking, model) (#191)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -360,6 +360,49 @@ export interface ModelTestResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/projects/:p/models/default (owner): narrow default-model switch — flips the same
|
||||
* top-level `default_model` the models page's whole-table PUT writes, without resending the
|
||||
* table (and thus without touching credentials). The pair must name a configured model
|
||||
* entry, exactly like the whole-table route's defaultModel validation.
|
||||
*/
|
||||
export interface DefaultModelUpdateRequest {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** Response mirrors what GET models reports as `defaultModel`. */
|
||||
export interface DefaultModelResponse {
|
||||
defaultModel: ModelRefDto;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New-chat defaults (the `[default_chat]` block of .project_config.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-Project new-chat defaults: prefill for the chat draft page. Every key is optional —
|
||||
* an absent key means "not set" (the pre-existing behavior). Serves as the GET response,
|
||||
* the PUT request body (whole-block replace: an omitted key clears it) and the PUT
|
||||
* response (the stored block). The default MODEL is deliberately not here: it stays the
|
||||
* top-level `default_model` served/written via the models routes (single-sourced with the
|
||||
* models page).
|
||||
*/
|
||||
export interface ChatDefaultsDto {
|
||||
/** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */
|
||||
agentId?: string;
|
||||
/** Prefilled Workspace directory; absent/empty = auto temp directory. */
|
||||
workspace?: string;
|
||||
/** Prefilled approval mode; absent = the built-in "allow-all". */
|
||||
approvalMode?: ApprovalMode;
|
||||
/**
|
||||
* Fallback thinking level for Agents whose config has no explicit `model.thinking_level`
|
||||
* (resolution chain: Agent explicit > this project default > built-in "medium"). Never
|
||||
* "none" — only the four selectable tiers.
|
||||
*/
|
||||
thinkingLevel?: Exclude<ThinkingLevelName, "none">;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault environment variables (Agent-level: agent_state/.vault.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,6 +38,7 @@ import { eventsRoutes, userChannelKey } from "./http/routes/events.js";
|
||||
import { projectsRoutes } from "./http/routes/projects.js";
|
||||
import { membersRoutes } from "./http/routes/members.js";
|
||||
import { modelsRoutes } from "./http/routes/models.js";
|
||||
import { chatDefaultsRoutes } from "./http/routes/chat-defaults.js";
|
||||
import { vaultRoutes } from "./http/routes/vault.js";
|
||||
import { scheduleRoutes } from "./http/routes/schedules.js";
|
||||
import { benchmarksRoutes } from "./http/routes/benchmarks.js";
|
||||
@@ -378,6 +379,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
|
||||
app.route("/api/projects", projectsRoutes(deps));
|
||||
app.route("/api/projects/:projectId/members", membersRoutes(deps));
|
||||
app.route("/api/projects/:projectId/models", modelsRoutes(deps));
|
||||
app.route("/api/projects/:projectId/chat-defaults", chatDefaultsRoutes(deps));
|
||||
app.route("/api/projects/:projectId/agents", agentsRoutes(deps));
|
||||
app.route("/api/projects/:projectId/dirs", dirsRoutes(deps));
|
||||
app.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps));
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* New-chat defaults routes: GET|PUT /api/projects/:p/chat-defaults (the `[default_chat]`
|
||||
* block of .project_config.toml). Any member can read (the draft page prefills from it);
|
||||
* only the owner can replace it. PUT is declarative whole-block replace: an omitted key
|
||||
* clears it, an empty body removes the block. The default MODEL is not part of this block —
|
||||
* it stays the top-level `default_model` maintained via the models routes.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { DEFAULT_CHAT_THINKING_LEVELS, isValidId } from "@prismshadow/penguin-core";
|
||||
import type { ChatDefaultsDto } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { optionalEnum, optionalString, readJson, requireValidId } from "../validate.js";
|
||||
import { APPROVAL_MODES } from "./sessions.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function chatDefaultsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Defensive id validation (FD-4).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
return c.json(await deps.projectConfigService.getChatDefaults(projectId));
|
||||
});
|
||||
|
||||
app.put("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
const body = await readJson(c);
|
||||
const req: ChatDefaultsDto = {};
|
||||
|
||||
// agentId must reference an existing Agent of this Project (a stale default would make
|
||||
// every prefilled draft 404). isValidId first: existence is checked by path
|
||||
// (system_config.yaml presence, the same rule the agent routes use), so a junk id must
|
||||
// never reach path construction.
|
||||
const agentId = optionalString(body, "agentId", { minLen: 1, maxLen: 64, label: "agentId" });
|
||||
if (agentId !== undefined) {
|
||||
if (!isValidId(agentId) || !(await deps.agentConfigService.exists(projectId, agentId))) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"unknown_agent",
|
||||
`agentId does not reference an Agent of this Project: ${agentId}.`,
|
||||
);
|
||||
}
|
||||
req.agentId = agentId;
|
||||
}
|
||||
|
||||
// Not validated as an existing directory on purpose: the default is a prefill, and the
|
||||
// directory is (re)checked when a Session is actually created. "" = clear (auto temp).
|
||||
const workspace = optionalString(body, "workspace", { maxLen: 4096, label: "workspace" });
|
||||
if (workspace !== undefined && workspace !== "") req.workspace = workspace;
|
||||
|
||||
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
|
||||
if (approvalMode !== undefined) req.approvalMode = approvalMode;
|
||||
|
||||
// Only the four selectable tiers — "none" is rejected (never offered as a default).
|
||||
const thinkingLevel = optionalEnum(body, "thinkingLevel", DEFAULT_CHAT_THINKING_LEVELS);
|
||||
if (thinkingLevel !== undefined) req.thinkingLevel = thinkingLevel;
|
||||
|
||||
return c.json(await deps.projectConfigService.setChatDefaults(projectId, req));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Model & credential config routes:
|
||||
* GET|PUT /api/projects/:p/models, POST /api/projects/:p/models/test (the model reference
|
||||
* GET|PUT /api/projects/:p/models, PUT /api/projects/:p/models/default,
|
||||
* POST /api/projects/:p/models/test (the model reference
|
||||
* `(provider, modelId)` is sent as a pair in the request body, avoiding URL-encoding
|
||||
* issues). Any member can read (api_key is masked); only the owner can modify or test.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type {
|
||||
DefaultModelResponse,
|
||||
ModelRefDto,
|
||||
ModelsUpdateRequest,
|
||||
ModelTestRequest,
|
||||
@@ -168,6 +170,24 @@ export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
// Narrow default-model switch (owner): flips the same top-level `default_model` the
|
||||
// whole-table PUT above maintains, without resending the table — project settings can
|
||||
// change the default without carrying credentials. The pair must name a configured
|
||||
// entry (400 otherwise, same rule as the whole-table route's defaultModel). No runtime
|
||||
// invalidation and no credentials_updated: existing Sessions pin their model at
|
||||
// creation, and no credential changes here.
|
||||
app.put("/default", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
const body = await readJson(c);
|
||||
const ref: ModelRefDto = {
|
||||
provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
|
||||
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
|
||||
};
|
||||
const defaultModel = await deps.projectConfigService.setDefaultModelRef(projectId, ref);
|
||||
return c.json({ defaultModel } satisfies DefaultModelResponse);
|
||||
});
|
||||
|
||||
// Connectivity test (owner): the model reference `(provider, modelId)` is sent as a pair
|
||||
// in the request body; sends one minimal request using that model's config. May include
|
||||
// not-yet-saved apiKey / baseUrl / clientType — when the model isn't in the config yet
|
||||
|
||||
@@ -61,7 +61,8 @@ const SESSION_TITLE_MAX = 120;
|
||||
const STAT_MAX_PATHS = 100;
|
||||
const STAT_MAX_PATH_LEN = 512;
|
||||
|
||||
const APPROVAL_MODES: readonly ApprovalMode[] = [
|
||||
/** The four approval modes (shared with the chat-defaults route's validation). */
|
||||
export const APPROVAL_MODES: readonly ApprovalMode[] = [
|
||||
"allow-all",
|
||||
"deny-all",
|
||||
"read-only",
|
||||
|
||||
@@ -19,6 +19,8 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import {
|
||||
CHAT_APPROVAL_MODES,
|
||||
DEFAULT_CHAT_THINKING_LEVELS,
|
||||
GenerativeModel,
|
||||
catalogEntryFor,
|
||||
defaultProjectConfig,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { LLMOutcome, ModelRef, OmniMessage } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
ChatDefaultsDto,
|
||||
ModelInfo,
|
||||
ModelPricingDto,
|
||||
ModelRefDto,
|
||||
@@ -224,6 +227,77 @@ export class ProjectConfigService {
|
||||
return optRef(raw.default_model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the default Model to an already-configured entry (the narrow
|
||||
* PUT /models/default route): rewrites only the top-level `default_model` — the SAME key
|
||||
* the models page's whole-table PUT maintains, so the two surfaces stay single-sourced —
|
||||
* preserving every other field via read-modify-write. The pair must name an entry in
|
||||
* `models` (the identical rule updateModels applies to `defaultModel`); a reference
|
||||
* outside the config is a 400, since createSession would error on it immediately. No
|
||||
* runtime invalidation: existing Sessions pin their model at creation, and this route
|
||||
* never touches credentials.
|
||||
*/
|
||||
async setDefaultModelRef(projectId: string, ref: ModelRefDto): Promise<ModelRefDto> {
|
||||
const raw = await this.readRaw(projectId);
|
||||
if (!asArray(raw.models).some((m) => entryMatches(m, ref.provider, ref.modelId))) {
|
||||
throw badRequest(
|
||||
`defaultModel must be included in models: ${showRef(ref.provider, ref.modelId)}.`,
|
||||
);
|
||||
}
|
||||
await this.writeRaw(projectId, {
|
||||
...raw,
|
||||
default_model: { provider: ref.provider, model_id: ref.modelId },
|
||||
});
|
||||
return { provider: ref.provider, modelId: ref.modelId };
|
||||
}
|
||||
|
||||
/**
|
||||
* New-chat defaults (`[default_chat]`): read leniently — same tolerance as core's
|
||||
* loadProjectConfig (an invalid value drops that key; a missing/malformed block reads as
|
||||
* empty). Members may read; only the fields present in the file appear in the DTO.
|
||||
*/
|
||||
async getChatDefaults(projectId: string): Promise<ChatDefaultsDto> {
|
||||
const raw = await this.readRaw(projectId);
|
||||
const t = asTable(raw.default_chat);
|
||||
const agentId = optStr(t.agent_id);
|
||||
const workspace = optStr(t.workspace);
|
||||
const approval = optStr(t.approval_mode);
|
||||
const thinking = optStr(t.thinking_level);
|
||||
return {
|
||||
...(agentId !== undefined ? { agentId } : {}),
|
||||
...(workspace !== undefined ? { workspace } : {}),
|
||||
...(approval !== undefined && (CHAT_APPROVAL_MODES as readonly string[]).includes(approval)
|
||||
? { approvalMode: approval as ChatDefaultsDto["approvalMode"] }
|
||||
: {}),
|
||||
...(thinking !== undefined &&
|
||||
(DEFAULT_CHAT_THINKING_LEVELS as readonly string[]).includes(thinking)
|
||||
? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole `[default_chat]` block (declarative PUT: an omitted key clears it;
|
||||
* an empty request removes the block entirely, restoring the pre-existing behavior).
|
||||
* Field validation (enums / agent existence) happens at the route; this is a
|
||||
* read-modify-write like setName — every other field (models, credentials, name, the
|
||||
* default model) is preserved. Returns the stored block as re-read from disk.
|
||||
*/
|
||||
async setChatDefaults(projectId: string, req: ChatDefaultsDto): Promise<ChatDefaultsDto> {
|
||||
const raw = await this.readRaw(projectId);
|
||||
const block: RawTable = {
|
||||
...(req.agentId !== undefined ? { agent_id: req.agentId } : {}),
|
||||
...(req.workspace !== undefined ? { workspace: req.workspace } : {}),
|
||||
...(req.approvalMode !== undefined ? { approval_mode: req.approvalMode } : {}),
|
||||
...(req.thinkingLevel !== undefined ? { thinking_level: req.thinkingLevel } : {}),
|
||||
};
|
||||
const next: RawTable = { ...raw };
|
||||
if (Object.keys(block).length > 0) next.default_chat = block;
|
||||
else delete next.default_chat;
|
||||
await this.writeRaw(projectId, next);
|
||||
return this.getChatDefaults(projectId);
|
||||
}
|
||||
|
||||
/** Pricing lookup for usage-recorder: the current pricing for this paired reference (undefined if none -> cost is NULL). */
|
||||
async getPricing(
|
||||
projectId: string,
|
||||
|
||||
Reference in New Issue
Block a user