diff --git a/packages/adapters/codex-local/src/index.test.ts b/packages/adapters/codex-local/src/index.test.ts index 213edf017e..9cb9589122 100644 --- a/packages/adapters/codex-local/src/index.test.ts +++ b/packages/adapters/codex-local/src/index.test.ts @@ -14,5 +14,6 @@ describe("codex local adapter metadata", () => { ]); expect(isCodexLocalFastModeSupported(DEFAULT_CODEX_LOCAL_MODEL)).toBe(true); expect(modelIds).not.toContain("gpt-5.3-codex"); + expect(modelIds).not.toContain("gpt-5.3-codex-spark"); }); }); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 709b2de09f..bddcc3dc7b 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -43,7 +43,6 @@ export const models = [ { id: "gpt-5.6-luna", label: "gpt-5.6-luna" }, { id: "gpt-5.4", label: "gpt-5.4" }, { id: "gpt-5.4-mini", label: "gpt-5.4-mini" }, - { id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" }, { id: "gpt-5", label: "gpt-5" }, { id: "o3", label: "o3" }, { id: "o4-mini", label: "o4-mini" }, @@ -57,12 +56,8 @@ 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", - // Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work. - modelReasoningEffort: "high", - }, + description: "Use an explicitly configured lower-cost Codex model without changing the primary model.", + adapterConfig: {}, source: "adapter_default", }, ]; diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index e0843ca2e6..0137372425 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -89,9 +89,9 @@ describe("buildCodexExecArgs", () => { ]); }); - it("ignores fast mode for unsupported models", () => { + it("ignores fast mode for known unsupported models", () => { const result = buildCodexExecArgs({ - model: "gpt-5.3-codex-spark", + model: "gpt-5", fastMode: true, }); @@ -104,7 +104,7 @@ describe("buildCodexExecArgs", () => { "exec", "--json", "--model", - "gpt-5.3-codex-spark", + "gpt-5", "-", ]); }); diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 2db24439dd..b3de0e6ecb 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -52,6 +52,7 @@ describe("adapter model listing", () => { expect(models.some((model) => model.id === "gpt-5.6-sol")).toBe(true); expect(models.some((model) => model.id === "gpt-5.6-terra")).toBe(true); expect(models.some((model) => model.id === "gpt-5.6-luna")).toBe(true); + expect(models.some((model) => model.id === "gpt-5.3-codex-spark")).toBe(false); expect(fetchSpy).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 23455f282e..eb95aa673e 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -268,7 +268,7 @@ describe("server adapter registry", () => { await expect(listAdapterModelProfiles("codex_local")).resolves.toEqual([ expect.objectContaining({ key: "cheap", - adapterConfig: expect.objectContaining({ model: "gpt-5.3-codex-spark" }), + adapterConfig: {}, source: "adapter_default", }), ]); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 83bcaeadd2..0f45292f1e 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -972,7 +972,7 @@ describe.sequential("agent permission routes", () => { .send({ name: "Builder", role: "engineer", - adapterType: "process", + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: { heartbeat: { @@ -991,11 +991,79 @@ describe.sequential("agent permission routes", () => { intervalSec: 3600, maxConcurrentRuns: 20, }, + modelProfiles: { + cheap: { enabled: false }, + }, }, }), ); }); + it("creates agents when optional adapter model profile discovery fails", async () => { + const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ + type: "failing_profile_discovery", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "failing_profile_discovery", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + listModelProfiles: async () => { + throw new Error("profile discovery unavailable"); + }, + }); + + try { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "failing_profile_discovery", + adapterConfig: {}, + runtimeConfig: { + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: {}, + }, + }, + }, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + runtimeConfig: { + heartbeat: { + enabled: false, + maxConcurrentRuns: 20, + }, + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: {}, + }, + }, + }, + }), + ); + } finally { + unregisterServerAdapter("failing_profile_discovery"); + } + }); + it("seeds opencode agent creation with the static default model without live discovery", async () => { mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue( new Error("`opencode models` should not be called during creation"), @@ -1082,7 +1150,7 @@ describe.sequential("agent permission routes", () => { .send({ name: "Builder", role: "engineer", - adapterType: "process", + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: { heartbeat: { @@ -1101,6 +1169,9 @@ describe.sequential("agent permission routes", () => { intervalSec: 3600, maxConcurrentRuns: 20, }, + modelProfiles: { + cheap: { enabled: false }, + }, }, }), ); diff --git a/server/src/__tests__/heartbeat-model-profile.test.ts b/server/src/__tests__/heartbeat-model-profile.test.ts index 80a973c5ff..2ed42a4bfa 100644 --- a/server/src/__tests__/heartbeat-model-profile.test.ts +++ b/server/src/__tests__/heartbeat-model-profile.test.ts @@ -21,7 +21,7 @@ const cheapProfile: AdapterModelProfileDefinition = { }; describe("heartbeat model profile application", () => { - it("uses the Codex local adapter cheap default when the agent has no runtime override", async () => { + it("keeps Codex on its primary model when cheap has no explicit model override", async () => { const modelProfile = resolveModelProfileApplication({ adapterModelProfiles: await listAdapterModelProfiles("codex_local"), agentRuntimeConfig: {}, @@ -29,17 +29,21 @@ describe("heartbeat model profile application", () => { contextSnapshot: {}, }); + const merged = mergeModelProfileAdapterConfig({ + baseConfig: { model: "primary" }, + modelProfile, + issueAdapterConfig: null, + }); + expect(modelProfile).toMatchObject({ requested: "cheap", requestedBy: "issue_override", applied: "cheap", configSource: "adapter_default", fallbackReason: null, - adapterConfig: { - model: "gpt-5.3-codex-spark", - modelReasoningEffort: "high", - }, + adapterConfig: {}, }); + expect(merged).toEqual({ model: "primary" }); }); it("applies cheap profile patches before explicit issue adapter config overrides", () => { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 32fbf97476..b484275e2e 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -67,6 +67,7 @@ import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/executio import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult, + AdapterModelProfileDefinition, } from "@paperclipai/adapter-utils"; import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js"; import { secretService } from "../services/secrets.js"; @@ -104,6 +105,7 @@ import { recoveryService } from "../services/recovery/service.js"; import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; import { readObject } from "../lib/objects.js"; import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js"; +import { logger } from "../middleware/logger.js"; import { AGENT_PROFILE_CHANGE_CONSENT_FIELDS, agentInstructionsChangeTargetKey, @@ -1107,7 +1109,24 @@ export function agentRoutes( }; } - function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record { + async function listNewAgentAdapterModelProfiles( + adapterType: string, + ): Promise { + try { + return await listAdapterModelProfiles(adapterType); + } catch (error) { + logger.warn( + { err: error, adapterType }, + "Failed to discover adapter model profiles while normalizing a new agent; continuing without profile defaults", + ); + return []; + } + } + + async function normalizeNewAgentRuntimeConfig( + adapterType: string, + runtimeConfig: unknown, + ): Promise> { const parsedRuntimeConfig = asRecord(runtimeConfig); const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {}; const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat); @@ -1121,6 +1140,19 @@ export function agentRoutes( } normalizedRuntimeConfig.heartbeat = heartbeat; + + const parsedModelProfiles = asRecord(normalizedRuntimeConfig.modelProfiles); + const modelProfiles = parsedModelProfiles ? { ...parsedModelProfiles } : {}; + if (!Object.prototype.hasOwnProperty.call(modelProfiles, "cheap")) { + const adapterModelProfiles = await listNewAgentAdapterModelProfiles(adapterType); + if (adapterModelProfiles.some((profile) => profile.key === "cheap")) { + modelProfiles.cheap = { enabled: false }; + } + } + if (Object.keys(modelProfiles).length > 0) { + normalizedRuntimeConfig.modelProfiles = modelProfiles; + } + return normalizedRuntimeConfig; } @@ -1191,7 +1223,7 @@ export function agentRoutes( ): Promise> { const entries = listRuntimeModelProfileAdapterConfigs(runtimeConfig); if (entries.length === 0) return runtimeConfig; - const adapterModelProfiles = await listAdapterModelProfiles(adapterType); + const adapterModelProfiles = await listNewAgentAdapterModelProfiles(adapterType); const normalizedRuntimeConfig = { ...runtimeConfig }; const modelProfiles = asRecord(runtimeConfig.modelProfiles) ?? {}; @@ -2391,7 +2423,7 @@ export function agentRoutes( const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( companyId, hireInput.adapterType, - normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig), + await normalizeNewAgentRuntimeConfig(hireInput.adapterType, hireInput.runtimeConfig), normalizedAdapterConfig, ); const normalizedHireInput = { @@ -2586,7 +2618,7 @@ export function agentRoutes( const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( companyId, createInput.adapterType, - normalizeNewAgentRuntimeConfig(createInput.runtimeConfig), + await normalizeNewAgentRuntimeConfig(createInput.adapterType, createInput.runtimeConfig), normalizedAdapterConfig, ); await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId); diff --git a/ui/src/lib/new-agent-runtime-config.test.ts b/ui/src/lib/new-agent-runtime-config.test.ts index bbac8c6eb2..af33566268 100644 --- a/ui/src/lib/new-agent-runtime-config.test.ts +++ b/ui/src/lib/new-agent-runtime-config.test.ts @@ -58,6 +58,18 @@ describe("buildNewAgentRuntimeConfig", () => { expect(config.modelProfiles).toBeUndefined(); }); + it("persists explicit cheap-profile opt-in when using the adapter default", () => { + const config = buildNewAgentRuntimeConfig({ + cheapModelEnabled: true, + }); + expect(config.modelProfiles).toEqual({ + cheap: { + enabled: true, + adapterConfig: {}, + }, + }); + }); + it("omits modelProfiles when cheap model is set but explicitly disabled", () => { const config = buildNewAgentRuntimeConfig({ cheapModel: "claude-sonnet-4-6", diff --git a/ui/src/lib/new-agent-runtime-config.ts b/ui/src/lib/new-agent-runtime-config.ts index 206a7797f8..c1376d81ad 100644 --- a/ui/src/lib/new-agent-runtime-config.ts +++ b/ui/src/lib/new-agent-runtime-config.ts @@ -20,11 +20,11 @@ export function buildNewAgentRuntimeConfig(input?: { const cheapModel = input?.cheapModel?.trim() ?? ""; const cheapEnabled = input?.cheapModelEnabled ?? false; - if (cheapModel && cheapEnabled) { + if (cheapEnabled) { config.modelProfiles = { cheap: { enabled: true, - adapterConfig: { model: cheapModel }, + adapterConfig: cheapModel ? { model: cheapModel } : {}, }, }; }