mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Add cheap model profiles for local adapters (#4881)
## Thinking Path > - Paperclip is a control plane for autonomous AI companies, where adapters are the boundary between the board, agents, and execution runtimes. > - Local adapters currently expose a primary runtime configuration, but operators often need a cheaper model lane for routine or low-risk work. > - That cheap lane has to stay adapter-owned: runtime profile settings should not mutate the primary adapter config or bypass existing auth/secret mediation. > - Issue creation also needs an ergonomic way to request primary, cheap, or custom model behavior for a selected assignee. > - This pull request adds a first-class `cheap` model profile contract across adapter capabilities, heartbeat config resolution, agent configuration, and issue creation. > - The benefit is cheaper task execution can be configured and requested explicitly while preserving adapter boundaries, secret handling, and audit visibility. ## What Changed - Added adapter model-profile capability metadata and a `cheap` profile contract for supported local adapters. - Applied `runtimeConfig.modelProfiles.cheap.adapterConfig` during heartbeat config resolution, including requested/applied/fallback run metadata. - Added agent configuration UI for cheap model profile settings without writing those settings into primary `adapterConfig`. - Added New Issue assignee model lane controls for Primary / Cheap / Custom and request payload handling. - Added run ledger profile badges and Storybook stories for the new cheap-lane UI states. - Added tests for validators, heartbeat model profile application, permission/secret mediation, UI payload helpers, and run ledger rendering. - Added committed UI verification screenshots under `docs/pr-screenshots/pap-2837/`. - Addressed Greptile review feedback around cheap-profile defaults, shared profile types, and fallback test data. ## Verification Local: - `pnpm exec vitest run packages/shared/src/validators/issue.test.ts server/src/__tests__/adapter-registry.test.ts server/src/__tests__/agent-permissions-routes.test.ts server/src/__tests__/heartbeat-model-profile.test.ts ui/src/components/IssueRunLedger.test.tsx ui/src/lib/agent-config-patch.test.ts ui/src/lib/issue-assignee-overrides.test.ts ui/src/lib/new-agent-runtime-config.test.ts` — passed, 8 files / 103 tests. - `pnpm exec vitest run ui/src/lib/new-agent-runtime-config.test.ts ui/src/components/IssueRunLedger.test.tsx` — passed after Greptile/rebase follow-up, 2 files / 17 tests. - `pnpm --filter @paperclipai/ui typecheck` — passed after Greptile/rebase follow-up. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — did not complete successfully in this local worktree: it stopped in pre-existing `@paperclipai/adapter-utils` sandbox/SSH fixture suites outside this PR diff. Failures were 5s local timeouts plus `git init -b` unsupported by this machine's Git 2.21.0. The branch-specific targeted suites above passed. - Branch was fetched/rebased onto `public-gh/master`; `git rev-list --left-right --count public-gh/master...HEAD` reports `0 9`. Remote PR checks on latest head `e30bf399146451c86cee98ed528d51d33fa5af5a`: - `policy` — passed. - `verify` — passed. - `e2e` — passed. - `Greptile Review` — passed, confidence score 5/5; Greptile review threads resolved. - `security/snyk (cryppadotta)` — passed. Screenshots: - [New issue cheap lane desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png) - [New issue custom lane desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png) - [New issue unsupported adapter desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png) - [Run ledger model profile badges desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png) - Mobile variants are also in `docs/pr-screenshots/pap-2837/`. ## Risks - Medium: heartbeat config mediation now merges runtime model profiles into adapter configs, so adapter secret normalization and host-command restrictions must keep covering nested config paths. - Medium: the UI adds another issue creation choice; unsupported adapters must keep hiding the cheap lane and preserve primary behavior. - Low migration risk: no database migration is included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent using GPT-5-class reasoning with repo tool use and command execution. Exact served model/context window was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1fe1067361
commit
a3de1d764d
60 changed files with 2216 additions and 151 deletions
|
|
@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -77,6 +78,7 @@ interface IssueDraft {
|
|||
assigneeId?: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId?: string;
|
||||
assigneeModelLane?: IssueModelLane;
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeChrome: boolean;
|
||||
|
|
@ -93,7 +95,12 @@ type StagedIssueFile = {
|
|||
title?: string | null;
|
||||
};
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
import {
|
||||
buildAssigneeAdapterOverrides,
|
||||
ISSUE_OVERRIDE_ADAPTER_TYPES,
|
||||
type IssueModelLane,
|
||||
} from "../lib/issue-assignee-overrides";
|
||||
|
||||
const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown";
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
|
|
@ -122,41 +129,6 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
|
|||
],
|
||||
} as const;
|
||||
|
||||
function buildAssigneeAdapterOverrides(input: {
|
||||
adapterType: string | null | undefined;
|
||||
modelOverride: string;
|
||||
thinkingEffortOverride: string;
|
||||
chrome: boolean;
|
||||
}): Record<string, unknown> | null {
|
||||
const adapterType = input.adapterType ?? null;
|
||||
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const adapterConfig: Record<string, unknown> = {};
|
||||
if (input.modelOverride) adapterConfig.model = input.modelOverride;
|
||||
if (input.thinkingEffortOverride) {
|
||||
if (adapterType === "codex_local") {
|
||||
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
adapterConfig.variant = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "claude_local") {
|
||||
adapterConfig.effort = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
adapterConfig.variant = input.thinkingEffortOverride;
|
||||
}
|
||||
}
|
||||
if (adapterType === "claude_local" && input.chrome) {
|
||||
adapterConfig.chrome = true;
|
||||
}
|
||||
|
||||
const overrides: Record<string, unknown> = {};
|
||||
if (Object.keys(adapterConfig).length > 0) {
|
||||
overrides.adapterConfig = adapterConfig;
|
||||
}
|
||||
return Object.keys(overrides).length > 0 ? overrides : null;
|
||||
}
|
||||
|
||||
function loadDraft(): IssueDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
|
|
@ -406,6 +378,7 @@ export function NewIssueDialog() {
|
|||
const [projectId, setProjectId] = useState("");
|
||||
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
const [assigneeModelLane, setAssigneeModelLane] = useState<IssueModelLane>("primary");
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
|
|
@ -496,6 +469,25 @@ export function NewIssueDialog() {
|
|||
const supportsAssigneeOverrides = Boolean(
|
||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
const getAdapterCapabilities = useAdapterCapabilities();
|
||||
const assigneeAdapterCapabilities = assigneeAdapterType
|
||||
? getAdapterCapabilities(assigneeAdapterType)
|
||||
: null;
|
||||
const assigneeSupportsCheapLane = Boolean(
|
||||
supportsAssigneeOverrides && assigneeAdapterCapabilities?.supportsModelProfiles,
|
||||
);
|
||||
|
||||
const { data: assigneeCheapProfiles } = useQuery({
|
||||
queryKey: effectiveCompanyId && assigneeAdapterType
|
||||
? queryKeys.agents.adapterModelProfiles(effectiveCompanyId, assigneeAdapterType)
|
||||
: ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"],
|
||||
queryFn: () => agentsApi.adapterModelProfiles(effectiveCompanyId!, assigneeAdapterType!),
|
||||
enabled: Boolean(effectiveCompanyId) && newIssueOpen && assigneeSupportsCheapLane,
|
||||
});
|
||||
const assigneeCheapProfile = useMemo(
|
||||
() => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null,
|
||||
[assigneeCheapProfiles],
|
||||
);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
|
|
@ -612,6 +604,7 @@ export function NewIssueDialog() {
|
|||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelLane,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
|
|
@ -663,6 +656,7 @@ export function NewIssueDialog() {
|
|||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelLane,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
|
|
@ -700,6 +694,7 @@ export function NewIssueDialog() {
|
|||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceId);
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setAssigneeModelLane("primary");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
|
|
@ -744,6 +739,7 @@ export function NewIssueDialog() {
|
|||
setShowApproverRow(!!(draft.approverValue));
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
|
|
@ -780,11 +776,15 @@ export function NewIssueDialog() {
|
|||
useEffect(() => {
|
||||
if (!supportsAssigneeOverrides) {
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelLane("primary");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
return;
|
||||
}
|
||||
if (!assigneeSupportsCheapLane && assigneeModelLane === "cheap") {
|
||||
setAssigneeModelLane("primary");
|
||||
}
|
||||
|
||||
const validThinkingValues =
|
||||
assigneeAdapterType === "codex_local"
|
||||
|
|
@ -795,7 +795,13 @@ export function NewIssueDialog() {
|
|||
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
||||
setAssigneeThinkingEffort("");
|
||||
}
|
||||
}, [supportsAssigneeOverrides, assigneeAdapterType, assigneeThinkingEffort]);
|
||||
}, [
|
||||
supportsAssigneeOverrides,
|
||||
assigneeAdapterType,
|
||||
assigneeThinkingEffort,
|
||||
assigneeSupportsCheapLane,
|
||||
assigneeModelLane,
|
||||
]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -816,6 +822,7 @@ export function NewIssueDialog() {
|
|||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelLane("primary");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
|
|
@ -841,6 +848,7 @@ export function NewIssueDialog() {
|
|||
setShowApproverRow(false);
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeModelLane("primary");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
|
|
@ -858,8 +866,14 @@ export function NewIssueDialog() {
|
|||
const currentTitle = titleRef.current.trim();
|
||||
const currentDescription = descriptionRef.current.trim();
|
||||
if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return;
|
||||
const effectiveLane = assigneeSupportsCheapLane
|
||||
? assigneeModelLane
|
||||
: assigneeModelLane === "cheap"
|
||||
? "primary"
|
||||
: assigneeModelLane;
|
||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||
adapterType: assigneeAdapterType,
|
||||
lane: effectiveLane,
|
||||
modelOverride: assigneeModelOverride,
|
||||
thinkingEffortOverride: assigneeThinkingEffort,
|
||||
chrome: assigneeChrome,
|
||||
|
|
@ -1557,36 +1571,84 @@ export function NewIssueDialog() {
|
|||
{assigneeOptionsOpen && (
|
||||
<div className="mt-2 rounded-md border border-border p-3 bg-muted/20 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Model</div>
|
||||
<InlineEntitySelector
|
||||
value={assigneeModelOverride}
|
||||
options={modelOverrideOptions}
|
||||
placeholder="Default model"
|
||||
disablePortal
|
||||
noneLabel="Default model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyMessage="No models found."
|
||||
onChange={setAssigneeModelOverride}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Thinking effort</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{thinkingEffortOptions.map((option) => (
|
||||
<div className="text-xs text-muted-foreground">Model lane</div>
|
||||
<div
|
||||
className="flex w-full overflow-hidden rounded-md border border-border"
|
||||
role="radiogroup"
|
||||
aria-label="Model lane"
|
||||
>
|
||||
{(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => (
|
||||
<button
|
||||
key={option.value || "default"}
|
||||
key={lane}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={assigneeModelLane === lane}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
|
||||
assigneeThinkingEffort === option.value && "bg-accent"
|
||||
"flex-1 px-2 py-1 text-xs capitalize transition-colors hover:bg-accent/40",
|
||||
assigneeModelLane === lane && "bg-accent text-foreground",
|
||||
)}
|
||||
onClick={() => setAssigneeThinkingEffort(option.value)}
|
||||
onClick={() => setAssigneeModelLane(lane)}
|
||||
>
|
||||
{option.label}
|
||||
{lane === "primary"
|
||||
? "Primary"
|
||||
: lane === "cheap"
|
||||
? "Cheap"
|
||||
: "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{assigneeModelLane === "cheap" && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Sends <code>modelProfile: "cheap"</code>{" "}
|
||||
{assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record<string, unknown>).model === "string"
|
||||
? <>· adapter default <code>{String((assigneeCheapProfile.adapterConfig as Record<string, unknown>).model)}</code></>
|
||||
: assigneeCheapProfile
|
||||
? <>· uses the agent's configured cheap profile</>
|
||||
: <>· falls back to the primary model if no cheap profile is configured</>}
|
||||
</p>
|
||||
)}
|
||||
{assigneeModelLane === "primary" && (
|
||||
<p className="text-[11px] text-muted-foreground">Runs on the agent's primary model.</p>
|
||||
)}
|
||||
{assigneeModelLane === "custom" && (
|
||||
<p className="text-[11px] text-muted-foreground">Override the model and effort for this issue only.</p>
|
||||
)}
|
||||
</div>
|
||||
{assigneeAdapterType === "claude_local" && (
|
||||
{assigneeModelLane === "custom" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Model</div>
|
||||
<InlineEntitySelector
|
||||
value={assigneeModelOverride}
|
||||
options={modelOverrideOptions}
|
||||
placeholder="Default model"
|
||||
disablePortal
|
||||
noneLabel="Default model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyMessage="No models found."
|
||||
onChange={setAssigneeModelOverride}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{assigneeModelLane === "custom" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Thinking effort</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{thinkingEffortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value || "default"}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
|
||||
assigneeThinkingEffort === option.value && "bg-accent"
|
||||
)}
|
||||
onClick={() => setAssigneeThinkingEffort(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{assigneeAdapterType === "claude_local" && assigneeModelLane === "custom" && (
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
||||
<ToggleSwitch
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue