diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e6ceb980792..d77384ee4a4 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -150,7 +150,7 @@ Invariant: every business record belongs to exactly one company. - `capabilities` text null - `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway` - `adapter_config` jsonb not null -- `runtime_config` jsonb not null default `{}` +- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config - `default_environment_id` uuid fk `environments.id` null - `context_mode` enum: `thin | fat` default `thin` - `budget_monthly_cents` int not null default 0 diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 9dcf068b238..c563ab2134c 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -20,6 +20,8 @@ export type { AdapterSkillContext, AdapterSessionCodec, AdapterModel, + AdapterModelProfileKey, + AdapterModelProfileDefinition, HireApprovedPayload, HireApprovedHookResult, ConfigFieldOption, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 034c1e60e06..d5b389612eb 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -144,6 +144,16 @@ export interface AdapterModel { label: string; } +export type AdapterModelProfileKey = "cheap"; + +export interface AdapterModelProfileDefinition { + key: AdapterModelProfileKey; + label: string; + description?: string; + adapterConfig: Record; + source?: "adapter_default" | "discovered"; +} + export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error"; export interface AdapterEnvironmentCheck { @@ -329,6 +339,8 @@ export interface ServerAdapterModule { supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; listModels?: () => Promise; + modelProfiles?: AdapterModelProfileDefinition[]; + listModelProfiles?: () => Promise; /** * Optional explicit refresh hook for model discovery. * Use this when the adapter caches discovered models and needs a bypass path diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 88e734eb3eb..9b928ae2a86 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -1,3 +1,5 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "claude_local"; export const label = "Claude Code (local)"; @@ -10,6 +12,19 @@ export const models = [ { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use Claude Sonnet as the lower-cost Claude Code lane while preserving the agent's primary model.", + adapterConfig: { + model: "claude-sonnet-4-6", + effort: "low", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# claude_local agent configuration Adapter: claude_local diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index e8f0d5a9425..ae2718208f6 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -1,5 +1,8 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "codex_local"; export const label = "Codex (local)"; + export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex"; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const; @@ -40,6 +43,19 @@ export const models = [ { id: "codex-mini-latest", label: "Codex Mini" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use the lowest-cost known Codex local model lane without changing the primary model.", + adapterConfig: { + model: "gpt-5.3-codex-spark", + modelReasoningEffort: "low", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# codex_local agent configuration Adapter: codex_local diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index ec26ff668ad..16fbda547f1 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -1,5 +1,8 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "cursor"; export const label = "Cursor CLI (local)"; + export const DEFAULT_CURSOR_LOCAL_MODEL = "auto"; const CURSOR_FALLBACK_MODEL_IDS = [ @@ -46,6 +49,18 @@ const CURSOR_FALLBACK_MODEL_IDS = [ export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id })); +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use Cursor's known Codex mini model as the budget lane instead of assuming auto is cheap.", + adapterConfig: { + model: "gpt-5.1-codex-mini", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# cursor agent configuration Adapter: cursor diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 64b7b99f087..0e7dcbf490c 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -1,5 +1,8 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "gemini_local"; export const label = "Gemini CLI (local)"; + export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; export const models = [ @@ -11,6 +14,18 @@ export const models = [ { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use Gemini Flash Lite as the budget Gemini CLI lane while preserving the primary model.", + adapterConfig: { + model: "gemini-2.5-flash-lite", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# gemini_local agent configuration Adapter: gemini_local diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index c8f37a81b2d..ff326f99b99 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -1,3 +1,5 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "opencode_local"; export const label = "OpenCode (local)"; @@ -11,6 +13,19 @@ export const models: Array<{ id: string; label: string }> = [ { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, ]; +export const modelProfiles: AdapterModelProfileDefinition[] = [ + { + key: "cheap", + label: "Cheap", + description: "Use OpenCode's known Codex mini model as the budget lane.", + adapterConfig: { + model: "openai/gpt-5.1-codex-mini", + variant: "low", + }, + source: "adapter_default", + }, +]; + export const agentConfigurationDoc = `# opencode_local agent configuration Adapter: opencode_local diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index a81750c36b5..6daaa59b30d 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -1,8 +1,12 @@ +import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; + export const type = "pi_local"; export const label = "Pi (local)"; export const models: Array<{ id: string; label: string }> = []; +export const modelProfiles: AdapterModelProfileDefinition[] = []; + export const agentConfigurationDoc = `# pi_local agent configuration Adapter: pi_local diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5515de9970d..16211b38e4f 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -73,6 +73,10 @@ export const AGENT_ROLE_LABELS: Record = { export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch"; + +export const MODEL_PROFILE_KEYS = ["cheap"] as const; +export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number]; + export const AGENT_ICON_NAMES = [ "bot", "cpu", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4935257e25d..a70ff848e1e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,6 +13,7 @@ export { AGENT_ROLE_LABELS, AGENT_DEFAULT_MAX_CONCURRENT_RUNS, WORKSPACE_BRANCH_ROUTINE_VARIABLE, + MODEL_PROFILE_KEYS, AGENT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, @@ -117,6 +118,7 @@ export { type AgentStatus, type AgentAdapterType, type AgentRole, + type ModelProfileKey, type AgentIconName, type IssueStatus, type IssuePriority, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index d86d4d03059..18aea077210 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -1,5 +1,6 @@ import type { AgentAdapterType, + ModelProfileKey, PauseReason, AgentRole, AgentStatus, @@ -13,6 +14,16 @@ export interface AgentPermissions { canCreateAgents: boolean; } +export interface AgentModelProfileConfig { + enabled?: boolean; + label?: string; + adapterConfig: Record; +} + +export interface AgentRuntimeConfig extends Record { + modelProfiles?: Partial>; +} + export type AgentInstructionsBundleMode = "managed" | "external"; export interface AgentInstructionsFileSummary { @@ -72,7 +83,7 @@ export interface Agent { capabilities: string | null; adapterType: AgentAdapterType; adapterConfig: Record; - runtimeConfig: Record; + runtimeConfig: AgentRuntimeConfig; defaultEnvironmentId?: string | null; budgetMonthlyCents: number; spentMonthlyCents: number; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 669575cc933..25166759ddc 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -74,7 +74,9 @@ export type { AgentAccessState, AgentChainOfCommandEntry, AgentDetail, + AgentModelProfileConfig, AgentPermissions, + AgentRuntimeConfig, AgentInstructionsBundleMode, AgentInstructionsFileSummary, AgentInstructionsFileDetail, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 853159be14b..2fa8cb8b5f5 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -6,6 +6,7 @@ import type { IssueExecutionStateStatus, IssueOriginKind, IssuePriority, + ModelProfileKey, IssueThreadInteractionContinuationPolicy, IssueThreadInteractionKind, IssueThreadInteractionStatus, @@ -59,6 +60,7 @@ export interface IssueLabel { } export interface IssueAssigneeAdapterOverrides { + modelProfile?: ModelProfileKey; adapterConfig?: Record; useProjectWorkspace?: boolean; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index a7150774136..cc561080d80 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -51,6 +51,18 @@ export const createAgentInstructionsBundleSchema = z.object({ }), }); +const agentModelProfileConfigSchema = z.object({ + enabled: z.boolean().optional(), + label: z.string().trim().min(1).optional(), + adapterConfig: z.record(z.unknown()), +}).strict(); + +export const agentRuntimeConfigSchema = z.object({ + modelProfiles: z.object({ + cheap: agentModelProfileConfigSchema.optional(), + }).strict().optional(), +}).catchall(z.unknown()); + export const createAgentSchema = z.object({ name: z.string().min(1), role: z.enum(AGENT_ROLES).optional().default("general"), @@ -62,7 +74,7 @@ export const createAgentSchema = z.object({ adapterType: agentAdapterTypeSchema, adapterConfig: adapterConfigSchema.optional().default({}), instructionsBundle: createAgentInstructionsBundleSchema.optional(), - runtimeConfig: z.record(z.unknown()).optional().default({}), + runtimeConfig: agentRuntimeConfigSchema.optional().default({}), defaultEnvironmentId: z.string().uuid().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), permissions: agentPermissionsSchema.optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index dfa28d726ba..7b916e6b7b8 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -107,6 +107,7 @@ export { createAgentSchema, createAgentHireSchema, updateAgentSchema, + agentRuntimeConfigSchema, agentInstructionsBundleModeSchema, updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 662224e4dac..c3c13e79b66 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -8,6 +8,7 @@ import { updateIssueSchema, upsertIssueDocumentSchema, } from "./issue.js"; +import { createAgentSchema } from "./agent.js"; describe("issue validators", () => { it("passes real line breaks through unchanged", () => { @@ -93,4 +94,67 @@ describe("issue validators", () => { expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH); }); + + it("accepts the cheap model profile in issue assignee adapter overrides", () => { + const parsed = createIssueSchema.parse({ + title: "Run a cheap heartbeat", + assigneeAdapterOverrides: { + modelProfile: "cheap", + }, + }); + + expect(parsed.assigneeAdapterOverrides?.modelProfile).toBe("cheap"); + }); + + it("rejects unknown issue model profile keys", () => { + const parsed = updateIssueSchema.safeParse({ + assigneeAdapterOverrides: { + modelProfile: "fast", + }, + }); + + expect(parsed.success).toBe(false); + }); + + it("validates agent runtime cheap model profile config without rejecting other runtime fields", () => { + const parsed = createAgentSchema.parse({ + name: "Coder", + adapterType: "codex_local", + runtimeConfig: { + heartbeat: { enabled: true }, + modelProfiles: { + cheap: { + enabled: true, + label: "Cheap Codex", + adapterConfig: { + model: "gpt-5.3-codex-spark", + }, + }, + }, + }, + }); + + expect(parsed.runtimeConfig.modelProfiles?.cheap?.adapterConfig).toEqual({ + model: "gpt-5.3-codex-spark", + }); + expect(parsed.runtimeConfig.heartbeat).toEqual({ enabled: true }); + }); + + it("rejects unknown agent runtime model profile keys", () => { + const parsed = createAgentSchema.safeParse({ + name: "Coder", + adapterType: "codex_local", + runtimeConfig: { + modelProfiles: { + fast: { + adapterConfig: { + model: "gpt-5-mini", + }, + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); }); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 882b3dd8dc4..9533e839dca 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -10,6 +10,7 @@ import { ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, + MODEL_PROFILE_KEYS, } from "../constants.js"; import { multilineTextSchema } from "./text.js"; @@ -44,6 +45,7 @@ export const issueExecutionWorkspaceSettingsSchema = z export const issueAssigneeAdapterOverridesSchema = z .object({ + modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(), adapterConfig: z.record(z.unknown()).optional(), useProjectWorkspace: z.boolean().optional(), }) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 8567d028b1c..99f91dea522 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -28,6 +28,7 @@ import { findActiveServerAdapter, findServerAdapter, listAdapterModels, + listAdapterModelProfiles, registerServerAdapter, requireServerAdapter, unregisterServerAdapter, @@ -79,6 +80,31 @@ describe("server adapter registry", () => { ]); }); + it("exposes adapter model profiles when adapters declare them", async () => { + const adapterWithProfiles: ServerAdapterModule = { + ...externalAdapter, + modelProfiles: [ + { + key: "cheap", + label: "Cheap", + adapterConfig: { model: "external-mini" }, + source: "adapter_default", + }, + ], + }; + + registerServerAdapter(adapterWithProfiles); + + expect(await listAdapterModelProfiles("external_test")).toEqual([ + { + key: "cheap", + label: "Cheap", + adapterConfig: { model: "external-mini" }, + source: "adapter_default", + }, + ]); + }); + it("removes external adapters when unregistered", () => { registerServerAdapter(externalAdapter); @@ -167,6 +193,45 @@ describe("server adapter registry", () => { expect(adapter!.supportsLocalAgentJwt).toBe(true); }); + it("built-in local adapters declare cheap model profile defaults where supported", async () => { + await expect(listAdapterModelProfiles("claude_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "claude-sonnet-4-6" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("codex_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "gpt-5.3-codex-spark" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("gemini_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "gemini-2.5-flash-lite" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("opencode_local")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "openai/gpt-5.1-codex-mini" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("cursor")).resolves.toEqual([ + expect.objectContaining({ + key: "cheap", + adapterConfig: expect.objectContaining({ model: "gpt-5.1-codex-mini" }), + source: "adapter_default", + }), + ]); + await expect(listAdapterModelProfiles("pi_local")).resolves.toEqual([]); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 9b27b32563a..5d193649ff8 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -6,6 +6,7 @@ export { findServerAdapter, findActiveServerAdapter, detectAdapterModel, + listAdapterModelProfiles, registerServerAdapter, unregisterServerAdapter, requireServerAdapter, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 20be46e11cc..94c38e2952a 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,4 @@ -import type { ServerAdapterModule } from "./types.js"; +import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, @@ -9,7 +9,11 @@ import { sessionCodec as claudeSessionCodec, getQuotaWindows as claudeGetQuotaWindows, } from "@paperclipai/adapter-claude-local/server"; -import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local"; +import { + agentConfigurationDoc as claudeAgentConfigurationDoc, + models as claudeModels, + modelProfiles as claudeModelProfiles, +} from "@paperclipai/adapter-claude-local"; import { execute as codexExecute, listCodexSkills, @@ -18,7 +22,11 @@ import { sessionCodec as codexSessionCodec, getQuotaWindows as codexGetQuotaWindows, } from "@paperclipai/adapter-codex-local/server"; -import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local"; +import { + agentConfigurationDoc as codexAgentConfigurationDoc, + models as codexModels, + modelProfiles as codexModelProfiles, +} from "@paperclipai/adapter-codex-local"; import { execute as cursorExecute, listCursorSkills, @@ -26,7 +34,11 @@ import { testEnvironment as cursorTestEnvironment, sessionCodec as cursorSessionCodec, } from "@paperclipai/adapter-cursor-local/server"; -import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; +import { + agentConfigurationDoc as cursorAgentConfigurationDoc, + models as cursorModels, + modelProfiles as cursorModelProfiles, +} from "@paperclipai/adapter-cursor-local"; import { execute as geminiExecute, listGeminiSkills, @@ -34,7 +46,11 @@ import { testEnvironment as geminiTestEnvironment, sessionCodec as geminiSessionCodec, } from "@paperclipai/adapter-gemini-local/server"; -import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local"; +import { + agentConfigurationDoc as geminiAgentConfigurationDoc, + models as geminiModels, + modelProfiles as geminiModelProfiles, +} from "@paperclipai/adapter-gemini-local"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -46,6 +62,7 @@ import { import { agentConfigurationDoc as openCodeAgentConfigurationDoc, models as openCodeModels, + modelProfiles as openCodeModelProfiles, } from "@paperclipai/adapter-opencode-local"; import { execute as openclawGatewayExecute, @@ -67,6 +84,7 @@ import { } from "@paperclipai/adapter-pi-local/server"; import { agentConfigurationDoc as piAgentConfigurationDoc, + modelProfiles as piModelProfiles, } from "@paperclipai/adapter-pi-local"; import { execute as hermesExecute, @@ -126,6 +144,7 @@ const claudeLocalAdapter: ServerAdapterModule = { sessionCodec: claudeSessionCodec, sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, + modelProfiles: claudeModelProfiles, listModels: listClaudeModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, @@ -144,6 +163,7 @@ const codexLocalAdapter: ServerAdapterModule = { sessionCodec: codexSessionCodec, sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, + modelProfiles: codexModelProfiles, listModels: listCodexModels, refreshModels: refreshCodexModels, supportsLocalAgentJwt: true, @@ -163,6 +183,7 @@ const cursorLocalAdapter: ServerAdapterModule = { sessionCodec: cursorSessionCodec, sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, + modelProfiles: cursorModelProfiles, listModels: listCursorModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, @@ -180,6 +201,7 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionCodec: geminiSessionCodec, sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, + modelProfiles: geminiModelProfiles, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", @@ -206,6 +228,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { syncSkills: syncOpenCodeSkills, sessionCodec: openCodeSessionCodec, models: openCodeModels, + modelProfiles: openCodeModelProfiles, sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, @@ -224,6 +247,7 @@ const piLocalAdapter: ServerAdapterModule = { sessionCodec: piSessionCodec, sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], + modelProfiles: piModelProfiles, listModels: listPiModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, @@ -474,6 +498,16 @@ export async function refreshAdapterModels(type: string): Promise<{ id: string; return adapter.models ?? []; } +export async function listAdapterModelProfiles(type: string): Promise { + const adapter = findActiveServerAdapter(type); + if (!adapter) return []; + if (adapter.listModelProfiles) { + const discovered = await adapter.listModelProfiles(); + if (discovered.length > 0) return discovered; + } + return adapter.modelProfiles ?? []; +} + export function listServerAdapters(): ServerAdapterModule[] { return Array.from(adaptersByType.values()); } diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index b8f32568e5c..0b728b9c3a6 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -22,6 +22,8 @@ export type { AdapterSkillContext, AdapterSessionCodec, AdapterModel, + AdapterModelProfileKey, + AdapterModelProfileDefinition, NativeContextManagement, ResolvedSessionCompactionPolicy, SessionCompactionPolicy, diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index efe52b4870c..8d1d2cece43 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -66,6 +66,7 @@ interface AdapterCapabilities { supportsSkills: boolean; supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; + supportsModelProfiles: boolean; } interface AdapterInfo { @@ -119,6 +120,7 @@ function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabili supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles), }; } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b3c7701b853..8cf4c64820b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -63,6 +63,7 @@ import { findActiveServerAdapter, findServerAdapter, listAdapterModels, + listAdapterModelProfiles, refreshAdapterModels, requireServerAdapter, } from "../adapters/index.js"; @@ -1064,6 +1065,14 @@ export function agentRoutes( res.json(models); }); + router.get("/companies/:companyId/adapters/:type/model-profiles", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const type = assertKnownAdapterType(req.params.type as string); + const profiles = await listAdapterModelProfiles(type); + res.json(profiles); + }); + router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 25535ed63a1..a602bf1df33 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8,11 +8,13 @@ import type { Db } from "@paperclipai/db"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + MODEL_PROFILE_KEYS, isEnvironmentDriverSupportedForAdapter, type BillingType, type EnvironmentLeaseStatus, type ExecutionWorkspace, type ExecutionWorkspaceConfig, + type ModelProfileKey, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -879,6 +881,7 @@ type SessionCompactionDecision = { }; interface ParsedIssueAssigneeAdapterOverrides { + modelProfile: ModelProfileKey | null; adapterConfig: Record | null; useProjectWorkspace: boolean | null; } @@ -1259,6 +1262,9 @@ function parseIssueAssigneeAdapterOverrides( raw: unknown, ): ParsedIssueAssigneeAdapterOverrides | null { const parsed = parseObject(raw); + const modelProfile = MODEL_PROFILE_KEYS.includes(parsed.modelProfile as ModelProfileKey) + ? parsed.modelProfile as ModelProfileKey + : null; const parsedAdapterConfig = parseObject(parsed.adapterConfig); const adapterConfig = Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null; @@ -1266,8 +1272,9 @@ function parseIssueAssigneeAdapterOverrides( typeof parsed.useProjectWorkspace === "boolean" ? parsed.useProjectWorkspace : null; - if (!adapterConfig && useProjectWorkspace === null) return null; + if (!modelProfile && !adapterConfig && useProjectWorkspace === null) return null; return { + modelProfile, adapterConfig, useProjectWorkspace, };