diff --git a/app/api/chat/runs/route.ts b/app/api/chat/runs/route.ts index 0f667665b..1434a07f9 100644 --- a/app/api/chat/runs/route.ts +++ b/app/api/chat/runs/route.ts @@ -32,7 +32,7 @@ export async function OPTIONS() { * - prompt: String prompt (mutually exclusive with messages) * - messages: Array of UIMessages (mutually exclusive with prompt) * - artistId: Optional UUID of the artist account - * - model: Optional model ID override (default anthropic/claude-haiku-4.5) + * - model: Optional model ID override (default moonshotai/kimi-k3) * - topic: Optional session title * - accountId: Optional accountId override (requires org API key) * diff --git a/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts b/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts index 88fbadf94..7c75797fc 100644 --- a/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts +++ b/lib/agents/generalAgent/__tests__/getGeneralAgent.test.ts @@ -117,7 +117,7 @@ describe("getGeneralAgent", () => { const result = await getGeneralAgent(body); - expect(result.model).toBe("openai/gpt-5.4-nano"); + expect(result.model).toBe("moonshotai/kimi-k3"); }); it("uses custom model when specified in body", async () => { diff --git a/lib/chat/__tests__/handleChatWorkflowStream.test.ts b/lib/chat/__tests__/handleChatWorkflowStream.test.ts index 1af062c8b..7a37c4a77 100644 --- a/lib/chat/__tests__/handleChatWorkflowStream.test.ts +++ b/lib/chat/__tests__/handleChatWorkflowStream.test.ts @@ -286,7 +286,7 @@ describe("handleChatWorkflowStream", () => { mockStartedRun(); await handleChatWorkflowStream(makeRequest()); const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as { modelId: string }; - expect(startArgs.modelId).toBe("anthropic/claude-haiku-4.5"); + expect(startArgs.modelId).toBe("moonshotai/kimi-k3"); }); // Bundle A.4 — forward the Privy JWT from the validated body into diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 568e546bc..30b2c4086 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -1,3 +1,4 @@ +import { DEFAULT_MODEL } from "@/lib/const"; import { NextRequest, NextResponse } from "next/server"; import { createUIMessageStreamResponse, type UIMessageChunk } from "ai"; import { start, getRun } from "workflow/api"; @@ -22,8 +23,6 @@ import { discoverSkills } from "@/lib/skills/discoverSkills"; import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; import generateUUID from "@/lib/uuid/generateUUID"; -const DEFAULT_MODEL_ID = "anthropic/claude-haiku-4.5"; - /** * Handles POST /api/chat/workflow. * @@ -90,7 +89,7 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise { }); expect(provisionRunSession).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "acc-1", title: "Scheduled generation" }), + expect.objectContaining({ + accountId: "acc-1", + title: "Scheduled generation", + // Provenance (chat#1956): the resolved model reaches the chat insert. + modelId: "test/model-y", + }), ); // the minted key is injected as recoupAccessToken AND threaded as ephemeralKeyId expect(buildRunAgentInput).toHaveBeenCalledWith( diff --git a/lib/chat/runs/__tests__/provisionRunSession.test.ts b/lib/chat/runs/__tests__/provisionRunSession.test.ts index 3dd3a13b3..404d438f2 100644 --- a/lib/chat/runs/__tests__/provisionRunSession.test.ts +++ b/lib/chat/runs/__tests__/provisionRunSession.test.ts @@ -56,7 +56,7 @@ describe("provisionRunSession", () => { }); it("installs global skills into the sandbox before discovering them", async () => { - await provisionRunSession({ accountId: "account-1", title: "t" }); + await provisionRunSession({ accountId: "account-1", title: "t", modelId: "test/model-x" }); // Headless runs must PROVISION skills, not just discover them (chat#1822). expect(installSessionGlobalSkills).toHaveBeenCalledWith({ @@ -69,10 +69,24 @@ describe("provisionRunSession", () => { expect(installOrder).toBeLessThan(discoverOrder); }); + // Provenance thread-through (chat#1956): the resolved model must reach the + // chat insert, not stop at the workflow input. + it("forwards modelId to createSessionWithInitialChat", async () => { + await provisionRunSession({ accountId: "account-1", title: "t", modelId: "test/model-x" }); + + expect(createSessionWithInitialChat).toHaveBeenCalledWith( + expect.objectContaining({ modelId: "test/model-x" }), + ); + }); + it("still completes the run when skill install fails (best-effort)", async () => { vi.mocked(installSessionGlobalSkills).mockRejectedValueOnce(new Error("install boom")); - const result = await provisionRunSession({ accountId: "account-1", title: "t" }); + const result = await provisionRunSession({ + accountId: "account-1", + title: "t", + modelId: "test/model-x", + }); expect(result.session).toEqual(updated); expect(discoverSkills).toHaveBeenCalled(); diff --git a/lib/chat/runs/__tests__/validateChatRunRequest.test.ts b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts index aae8f0903..79c2db924 100644 --- a/lib/chat/runs/__tests__/validateChatRunRequest.test.ts +++ b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts @@ -50,7 +50,17 @@ describe("validateChatRunRequest", () => { const noModel = await validateChatRunRequest(req({ prompt: "hi" })); if (noModel instanceof NextResponse) throw new Error("unexpected error"); - expect(noModel.modelId).toBe("anthropic/claude-haiku-4.5"); + expect(noModel.modelId).toBe("moonshotai/kimi-k3"); + + // cubic P2: an empty or whitespace model must normalize to the default, + // never persist as "" (provenance) or reach the workflow as "". + const emptyModel = await validateChatRunRequest(req({ prompt: "hi", model: "" })); + if (emptyModel instanceof NextResponse) throw new Error("unexpected error"); + expect(emptyModel.modelId).toBe("moonshotai/kimi-k3"); + + const blankModel = await validateChatRunRequest(req({ prompt: "hi", model: " " })); + if (blankModel instanceof NextResponse) throw new Error("unexpected error"); + expect(blankModel.modelId).toBe("moonshotai/kimi-k3"); }); it("rejects when neither prompt nor messages is provided (400)", async () => { diff --git a/lib/chat/runs/handleStartChatRun.ts b/lib/chat/runs/handleStartChatRun.ts index 4122834c8..5328145c9 100644 --- a/lib/chat/runs/handleStartChatRun.ts +++ b/lib/chat/runs/handleStartChatRun.ts @@ -46,6 +46,7 @@ export async function handleStartChatRun(request: NextRequest): Promise { const created = await createSessionWithInitialChat({ accountId, title, chatTitle: "Scheduled generation", artistId, + modelId, }); if (created.ok === false) { throw new Error( diff --git a/lib/chat/runs/validateChatRunRequest.ts b/lib/chat/runs/validateChatRunRequest.ts index 154f47e6c..342fc14f0 100644 --- a/lib/chat/runs/validateChatRunRequest.ts +++ b/lib/chat/runs/validateChatRunRequest.ts @@ -6,9 +6,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { errorResponse } from "@/lib/networking/errorResponse"; import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; import { generateUUID } from "@/lib/uuid/generateUUID"; - -/** Default model for headless generation when the caller omits `model`. */ -export const DEFAULT_RUN_MODEL_ID = "anthropic/claude-haiku-4.5"; +import { DEFAULT_MODEL } from "@/lib/const"; /** * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, @@ -78,11 +76,15 @@ export async function validateChatRunRequest( ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: trimmedPrompt }] }] : (messages as UIMessage[]); + // `||` not `??`: an empty/whitespace model must fall back too, or "" would + // be persisted as provenance and sent to the workflow as the model id. + const trimmedModel = typeof model === "string" ? model.trim() : ""; + return { accountId: auth.accountId, orgId: auth.orgId, messages: uiMessages, artistId, - modelId: model ?? DEFAULT_RUN_MODEL_ID, + modelId: trimmedModel || DEFAULT_MODEL, }; } diff --git a/lib/const.ts b/lib/const.ts index 86a460432..3af5c33b9 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -9,7 +9,14 @@ export const SMART_ACCOUNT_ADDRESS = "0xbAf31935ED514e8F7da81D0A730AB5362DEEEEb7 export const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as Address; export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.PAYMASTER_KEY}`; export const IMAGE_GENERATE_PRICE = "0.15"; -export const DEFAULT_MODEL = "openai/gpt-5.4-nano"; +/** + * THE default model, everywhere a caller picks none: interactive chats, + * headless runs (persisted to `chats.model_id` at provision time, chat#1956), + * evals, catalog batch analysis, and the general agent's fallback. + * kimi-k3 chosen 2026-08-12: best recall and lowest cost in a 4-model A/B on + * a production roster-brief task (api#830 review thread). + */ +export const DEFAULT_MODEL = "moonshotai/kimi-k3"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Base URL for the public API documentation site */ diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts index 043b65e6e..898ba58cc 100644 --- a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -68,6 +68,9 @@ describe("createSessionHandler — persistence", () => { const chatArgs = vi.mocked(insertChat).mock.calls[0][0]; expect(chatArgs.session_id).toBe("sess_1"); expect(chatArgs.title).toBe("New chat"); + // Provenance (chat#1956): every writer sets model_id explicitly so the + // chats.model_id column default can be dropped. + expect(chatArgs.model_id).toBe("moonshotai/kimi-k3"); }); it("uses auth.accountId for personal sessions", async () => { diff --git a/lib/sessions/__tests__/createSessionWithInitialChat.test.ts b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts index f30286ffd..95f9a9a75 100644 --- a/lib/sessions/__tests__/createSessionWithInitialChat.test.ts +++ b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts @@ -16,7 +16,13 @@ vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); -const args = { accountId: "acc-1", title: "T", chatTitle: "New chat", artistId: "art-1" }; +const args = { + accountId: "acc-1", + title: "T", + chatTitle: "New chat", + artistId: "art-1", + modelId: "test/model-x", +}; describe("createSessionWithInitialChat", () => { beforeEach(() => { @@ -38,6 +44,13 @@ describe("createSessionWithInitialChat", () => { ); }); + // Provenance: the chat row must record the model that will run (chat#1956) — + // never rely on the chats.model_id column default to fill it in. + it("writes the caller's modelId onto the inserted chat row", async () => { + await createSessionWithInitialChat(args); + expect(insertChat).toHaveBeenCalledWith(expect.objectContaining({ model_id: "test/model-x" })); + }); + it("uses workspaceAccountId for the repo when provided", async () => { await createSessionWithInitialChat({ ...args, workspaceAccountId: "org-9" }); expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: "org-9" }); diff --git a/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts b/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts index efc6797cd..2d91e91a4 100644 --- a/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts +++ b/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts @@ -95,6 +95,9 @@ describe("createSessionChatHandler", () => { expect(insertArgs.id).toBe("chat_requested"); expect(insertArgs.session_id).toBe("sess_1"); expect(insertArgs.title).toBe("New chat"); + // Provenance (chat#1956): every writer sets model_id explicitly so the + // chats.model_id column default can be dropped. + expect(insertArgs.model_id).toBe("moonshotai/kimi-k3"); }); it("creates a chat with a generated id when no id is provided", async () => { diff --git a/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts index 624a7f75e..10a08e3da 100644 --- a/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts @@ -70,7 +70,7 @@ describe("getSessionChatsHandler", () => { defaultModelId: string; }; expect(body.chats).toEqual(summaries); - expect(body.defaultModelId).toBe("openai/gpt-5.4-nano"); + expect(body.defaultModelId).toBe("moonshotai/kimi-k3"); expect(getChatSummaries).toHaveBeenCalledWith({ sessionId: "sess_1", accountId, diff --git a/lib/sessions/chats/createSessionChatHandler.ts b/lib/sessions/chats/createSessionChatHandler.ts index 868691246..433bdbc9a 100644 --- a/lib/sessions/chats/createSessionChatHandler.ts +++ b/lib/sessions/chats/createSessionChatHandler.ts @@ -5,6 +5,7 @@ import { validateCreateSessionChatRequest } from "@/lib/sessions/chats/validateC import { selectChats } from "@/lib/supabase/chats/selectChats"; import { insertChat } from "@/lib/supabase/chats/insertChat"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; +import { DEFAULT_MODEL } from "@/lib/const"; const INITIAL_CHAT_TITLE = "New chat"; @@ -53,6 +54,7 @@ export async function createSessionChatHandler( id: requestedChatId ?? generateUUID(), session_id: sessionId, title: INITIAL_CHAT_TITLE, + model_id: DEFAULT_MODEL, }); if (!chatRow) { diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index 9667695cd..4ec02ed9f 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -9,6 +9,7 @@ import { import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; +import { DEFAULT_MODEL } from "@/lib/const"; const INITIAL_CHAT_TITLE = "New chat"; @@ -49,6 +50,7 @@ export async function createSessionHandler(request: NextRequest): Promise { const cloneUrl = await ensurePersonalRepo({ accountId: workspaceAccountId ?? accountId }); if (!cloneUrl) return { ok: false, reason: "repo" }; @@ -50,7 +53,12 @@ export async function createSessionWithInitialChat({ ); if (!session) return { ok: false, reason: "insert" }; - const chat = await insertChat({ id: generateUUID(), session_id: session.id, title: chatTitle }); + const chat = await insertChat({ + id: generateUUID(), + session_id: session.id, + title: chatTitle, + model_id: modelId, + }); if (!chat) { const rolledBack = await deleteSessionById(session.id); if (!rolledBack) {