From 5f7091b92281c276adbad8fd3922a5a88a7c4e6e Mon Sep 17 00:00:00 2001 From: Zero Date: Wed, 8 Jul 2026 13:26:52 +0800 Subject: [PATCH 01/10] fix: send chat model selections by model id --- .../__tests__/chat-messages.bdd.test.ts | 11 +- .../__tests__/helpers/api-bdd-chat-files.ts | 48 ++++++-- .../zero-chat-threads-model-selection.test.ts | 5 +- .../src/signals/routes/zero-chat-messages.ts | 42 ++++++- .../routes/zero-chat-threads-create.ts | 23 +++- .../zero-chat-threads-model-selection.ts | 36 +++++- .../chat-page/optimistic-chat-thread-page.ts | 9 +- .../remote-chat-thread-data-source.ts | 7 +- .../zero-page/__tests__/chat-test-helpers.ts | 53 ++++++-- .../src/contracts/chat-threads.ts | 115 +++++++++++++++--- 10 files changed, 277 insertions(+), 72 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts index a34771cff98..1f0dbad754e 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts @@ -174,6 +174,7 @@ interface ChatRunSendBody { readonly threadId?: string; readonly clientThreadId?: string; readonly clientMessageId?: string; + readonly model?: string; readonly modelSelection?: ModelSelectionRequest; readonly runOptions?: ChatRunOptionsRequest; readonly generationTemplate?: GenerationTemplateRequest; @@ -1860,10 +1861,7 @@ describe("CHAT-02: explicit provider pins", () => { agentId, prompt: "run codex fast with switch off", clientThreadId: switchOffThreadId, - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.5", - }, + model: "gpt-5.5", runOptions: { codexServiceTier: "fast" }, }, [400], @@ -1881,10 +1879,7 @@ describe("CHAT-02: explicit provider pins", () => { const fast = await sendChatRun(actor, { agentId, prompt: "run codex fast", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.5", - }, + model: "gpt-5.5", runOptions: { codexServiceTier: "fast" }, }); expect((await chat.readThread(actor, fast.threadId)).codexServiceTier).toBe( diff --git a/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts b/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts index bca58c7d2c5..a8c37c4ef30 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts @@ -176,6 +176,7 @@ type BddSendMessageBody = readonly threadId?: string; readonly clientThreadId?: string; readonly modelProvider?: string; + readonly model?: string; readonly modelSelection?: ModelSelectionRequest | null; readonly runOptions?: ChatRunOptionsRequest; readonly generationTemplate?: GenerationTemplateRequest; @@ -459,6 +460,7 @@ export function createChatFilesBddApi(context: TestContext) { readonly title?: string; readonly clientThreadId?: string; readonly eventId?: string; + readonly model?: string; readonly modelSelection?: ModelSelectionRequest; }, ): Promise<{ readonly id: string; readonly title: string | null }> { @@ -467,8 +469,9 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), body: { ...body, - modelSelection: - body.modelSelection ?? defaultCreateThreadModelSelection(), + ...(body.model === undefined && body.modelSelection === undefined + ? { model: defaultCreateThreadModelSelection().selectedModel } + : {}), }, }), [201], @@ -483,6 +486,7 @@ export function createChatFilesBddApi(context: TestContext) { readonly title?: string; readonly clientThreadId?: string; readonly eventId?: string; + readonly model?: string; readonly modelSelection?: ModelSelectionRequest; }, statuses: readonly (201 | 401 | 402 | 404)[], @@ -492,8 +496,9 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), body: { ...body, - modelSelection: - body.modelSelection ?? defaultCreateThreadModelSelection(), + ...(body.model === undefined && body.modelSelection === undefined + ? { model: defaultCreateThreadModelSelection().selectedModel } + : {}), }, }), statuses, @@ -855,7 +860,12 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), params: { id: threadId }, body: { - modelSelection, + ...(modelSelection === null + ? { model: null } + : modelSelection?.modelProviderId === + MODEL_FIRST_SELECTION_PROVIDER_ID + ? { model: modelSelection.selectedModel } + : { modelSelection }), codexServiceTier: options?.codexServiceTier, eventId: options?.eventId, }, @@ -879,7 +889,12 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), params: { id: threadId }, body: { - modelSelection, + ...(modelSelection === null + ? { model: null } + : modelSelection?.modelProviderId === + MODEL_FIRST_SELECTION_PROVIDER_ID + ? { model: modelSelection.selectedModel } + : { modelSelection }), codexServiceTier: options?.codexServiceTier, eventId: options?.eventId, }, @@ -1199,12 +1214,12 @@ export function createChatFilesBddApi(context: TestContext) { const requestBody = "prompt" in body ? (() => { - const modelSelection = - body.modelSelection !== undefined - ? body.modelSelection - : body.threadId === undefined - ? defaultCreateThreadModelSelection() - : undefined; + const defaultModel = + body.threadId === undefined && + body.model === undefined && + body.modelSelection === undefined + ? defaultCreateThreadModelSelection().selectedModel + : undefined; return { agentId: body.agentId, prompt: body.prompt, @@ -1217,7 +1232,14 @@ export function createChatFilesBddApi(context: TestContext) { ...(body.modelProvider === undefined ? {} : { modelProvider: body.modelProvider }), - ...(modelSelection === undefined ? {} : { modelSelection }), + ...(body.model === undefined + ? defaultModel === undefined + ? {} + : { model: defaultModel } + : { model: body.model }), + ...(body.modelSelection === undefined + ? {} + : { modelSelection: body.modelSelection }), ...(body.runOptions === undefined ? {} : { runOptions: body.runOptions }), diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts index 56e56756ce1..3bd6d6535d9 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts @@ -88,10 +88,7 @@ describe("POST /api/zero/chat-threads/:id/model-selection", () => { headers: { authorization: `Bearer ${token}` }, params: { id: fixture.threadId }, body: { - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-5", - }, + model: "claude-sonnet-5", }, }), [204], diff --git a/turbo/apps/api/src/signals/routes/zero-chat-messages.ts b/turbo/apps/api/src/signals/routes/zero-chat-messages.ts index bbd625aa005..e493244147f 100644 --- a/turbo/apps/api/src/signals/routes/zero-chat-messages.ts +++ b/turbo/apps/api/src/signals/routes/zero-chat-messages.ts @@ -106,6 +106,7 @@ interface NormalSendBody { readonly chatThreadEventId?: string; readonly chatThreadSortEventId?: string; readonly modelProvider?: string; + readonly model?: string; readonly modelSelection?: { readonly modelProviderId: string; readonly selectedModel: string; @@ -464,6 +465,41 @@ function isNormalSendBody(body: SendBody): body is NormalSendBody { return "prompt" in body && body.prompt !== undefined; } +function modelFirstSelection( + selectedModel: string, +): NonNullable { + return { + modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, + selectedModel, + }; +} + +function normalizeNormalSendBody( + body: NormalSendBody, +): + | { readonly ok: true; readonly data: NormalSendBody } + | { + readonly ok: false; + readonly response: ReturnType; + } { + if (body.model !== undefined && body.modelSelection !== undefined) { + return { + ok: false, + response: badRequestMessage("Use model instead of modelSelection"), + }; + } + if (body.model === undefined || body.modelSelection !== undefined) { + return { ok: true, data: body }; + } + return { + ok: true, + data: { + ...body, + modelSelection: modelFirstSelection(body.model), + }, + }; +} + function hasAgentSessionId( value: unknown, ): value is { readonly agentSessionId: string } { @@ -3039,13 +3075,17 @@ const sendChatMessageInner$ = command( if (!isNormalSendBody(body.data)) { return badRequestMessage("Prompt is required"); } + const normalizedBody = normalizeNormalSendBody(body.data); + if (!normalizedBody.ok) { + return normalizedBody.response; + } const apiStartTime = now(); const timing = new ApiDispatchTimingCollector(); return await set( sendNormalMessage$, { - body: body.data, + body: normalizedBody.data, auth, userId: auth.userId, orgId: auth.orgId, diff --git a/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts b/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts index 5f519525d7d..20011190136 100644 --- a/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts +++ b/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts @@ -1,12 +1,15 @@ import { command } from "ccstate"; -import { chatThreadsContract } from "@vm0/api-contracts/contracts/chat-threads"; +import { + chatThreadsContract, + MODEL_FIRST_SELECTION_PROVIDER_ID, +} from "@vm0/api-contracts/contracts/chat-threads"; import { organizationAuthContext$ } from "../auth/auth-context"; import { authRoute } from "../auth/auth-route"; import { bodyResultOf } from "../context/request"; import { writeDb$ } from "../external/db"; import { publishThreadListChanged } from "../external/realtime"; -import { notFound } from "../../lib/error"; +import { badRequestMessage, notFound } from "../../lib/error"; import { createChatThread$ } from "../services/zero-chat-thread.service"; import { zeroComposeExists } from "../services/zero-compose-data.service"; import { resolveModelSelectionPin } from "../services/zero-model-selection.service"; @@ -14,6 +17,13 @@ import type { RouteEntry } from "../route-entry"; const createBody$ = bodyResultOf(chatThreadsContract.create); +function modelFirstSelection(selectedModel: string) { + return { + modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, + selectedModel, + }; +} + const createInner$ = command(async ({ get, set }, signal: AbortSignal) => { const auth = get(organizationAuthContext$); const body = await get(createBody$); @@ -33,11 +43,18 @@ const createInner$ = command(async ({ get, set }, signal: AbortSignal) => { return notFound("Agent not found"); } + const modelSelection = + body.data.modelSelection ?? + (body.data.model ? modelFirstSelection(body.data.model) : undefined); + if (!modelSelection) { + return badRequestMessage("A model selection is required"); + } + const pin = await resolveModelSelectionPin({ db: set(writeDb$), orgId: auth.orgId, userId: auth.userId, - modelSelection: body.data.modelSelection, + modelSelection, }); signal.throwIfAborted(); if ("status" in pin) { diff --git a/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts b/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts index 0f4722b545b..669dc1ef46c 100644 --- a/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts +++ b/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts @@ -1,6 +1,9 @@ import { command } from "ccstate"; import { and, eq } from "drizzle-orm"; -import { chatThreadModelSelectionContract } from "@vm0/api-contracts/contracts/chat-threads"; +import { + chatThreadModelSelectionContract, + MODEL_FIRST_SELECTION_PROVIDER_ID, +} from "@vm0/api-contracts/contracts/chat-threads"; import { chatThreads } from "@vm0/db/schema/chat-thread"; import { isFeatureEnabled } from "@vm0/core/feature-switch"; import { FeatureSwitchKey } from "@vm0/core/feature-switch-key"; @@ -25,6 +28,29 @@ const modelSelectionBody$ = bodyResultOf( chatThreadModelSelectionContract.update, ); +function modelFirstSelection(selectedModel: string) { + return { + modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, + selectedModel, + }; +} + +function modelSelectionFromBody(body: { + readonly model?: string | null; + readonly modelSelection?: { + readonly modelProviderId: string; + readonly selectedModel: string; + } | null; +}) { + if (body.modelSelection !== undefined) { + return body.modelSelection; + } + if (body.model === undefined) { + return undefined; + } + return body.model === null ? null : modelFirstSelection(body.model); +} + function isCodexFastServiceTierModel( model: string | null | undefined, ): boolean { @@ -86,12 +112,16 @@ const updateModelSelectionInner$ = command( } const writeDb = set(writeDb$); - const pin = body.data.modelSelection + const modelSelection = modelSelectionFromBody(body.data); + if (modelSelection === undefined) { + return badRequestMessage("A model selection is required"); + } + const pin = modelSelection ? await resolveModelSelectionPin({ db: writeDb, orgId: auth.orgId, userId: auth.userId, - modelSelection: body.data.modelSelection, + modelSelection, }) : { modelProviderId: null, diff --git a/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts b/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts index cc357c2b5e0..401661f4e1b 100644 --- a/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts +++ b/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts @@ -40,10 +40,7 @@ import { userModelPreference$ } from "../external/user-model-preference.ts"; import { featureSwitch$ } from "../external/feature-switch.ts"; import { generationTemplateForFeatureSwitches } from "./generation-template-feature-switch.ts"; import { logger } from "../log.ts"; -import { - modelSelectionRequestFromSelection, - runOptionsFromModelProviderSelection, -} from "./model-selection-request.ts"; +import { runOptionsFromModelProviderSelection } from "./model-selection-request.ts"; import type { ModelProviderSelection } from "../../views/zero-page/components/model-provider-picker.tsx"; import { registerOptimisticChatThreadEvent$ } from "./chat-thread-event-sourcing.ts"; @@ -284,9 +281,7 @@ async function createChatThread(args: { agentId: args.agentId, clientThreadId: args.clientThreadId, eventId: args.eventId, - modelSelection: modelSelectionRequestFromSelection( - args.modelSelection, - )!, + model: args.modelSelection.selectedModel, ...(args.title ? { title: args.title } : {}), }, fetchOptions: { signal: args.signal }, diff --git a/turbo/apps/platform/src/signals/chat-page/remote-chat-thread-data-source.ts b/turbo/apps/platform/src/signals/chat-page/remote-chat-thread-data-source.ts index a8fd9318c3c..210ec05eb90 100644 --- a/turbo/apps/platform/src/signals/chat-page/remote-chat-thread-data-source.ts +++ b/turbo/apps/platform/src/signals/chat-page/remote-chat-thread-data-source.ts @@ -13,10 +13,7 @@ import { import { accept } from "../../lib/accept.ts"; import { nowDate } from "../../lib/time.ts"; import { zeroClient$ } from "../api-client.ts"; -import { - modelSelectionRequestFromSelection, - threadCodexServiceTierFromSelection, -} from "./model-selection-request.ts"; +import { threadCodexServiceTierFromSelection } from "./model-selection-request.ts"; import { setAblyLoop$, setAblyPayloadLoop$ } from "../realtime.ts"; import { createDeferredPromise, @@ -114,7 +111,7 @@ const patchModelSelection$ = command( client.update({ params: { id: threadId }, body: { - modelSelection: modelSelectionRequestFromSelection(modelSelection), + model: modelSelection?.selectedModel ?? null, codexServiceTier: threadCodexServiceTierFromSelection(modelSelection), eventId, }, diff --git a/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts b/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts index 834cda9c410..4e76b801cde 100644 --- a/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts +++ b/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts @@ -15,6 +15,7 @@ import { chatThreadModelSelectionContract, chatThreadMessagesContract, chatMessagesContract, + MODEL_FIRST_SELECTION_PROVIDER_ID, type ChatRunOptionsRequest, type CodexServiceTier, type GenerationTemplateRequest, @@ -44,6 +45,26 @@ const DEFAULT_AGENT_ID = "c0000000-0000-4000-a000-000000000001"; const MOCK_RUN_ID = "d0000000-0000-4000-a000-000000000001"; const SUB_AGENT_ID = "a1111111-0000-4000-a000-000000000001"; +function modelFirstSelection(selectedModel: string): ModelSelectionRequest { + return { + modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, + selectedModel, + }; +} + +function modelSelectionFromBody(body: { + readonly model?: string | null; + readonly modelSelection?: ModelSelectionRequest | null; +}): ModelSelectionRequest | null | undefined { + if (body.modelSelection !== undefined) { + return body.modelSelection; + } + if (body.model === undefined) { + return undefined; + } + return body.model === null ? null : modelFirstSelection(body.model); +} + export function mockSubagentThread(context: TestContext, _threadId: string) { context.mocks.data.team([ { @@ -429,6 +450,7 @@ export function mockChatLifecycle( }[]; hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; + model?: string; modelSelection?: ModelSelectionRequest | null; runOptions?: ChatRunOptionsRequest; computerUseHostId?: string | null; @@ -436,13 +458,16 @@ export function mockChatLifecycle( }) => void; onSendRequest?: (body: { clientThreadId?: string; + model?: string; modelSelection?: ModelSelectionRequest | null; }) => void; onThreadCreate?: (body: { clientThreadId?: string; + model?: string; modelSelection: ModelSelectionRequest; }) => void; onModelSelectionUpdate?: (body: { + model?: string | null; modelSelection?: ModelSelectionRequest | null; codexServiceTier?: CodexServiceTier | null; }) => void; @@ -649,6 +674,7 @@ export function mockChatLifecycle( clientMessageId?: string; hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; + model?: string; modelSelection?: ModelSelectionRequest | null; runOptions?: ChatRunOptionsRequest; }) => { @@ -659,13 +685,14 @@ export function mockChatLifecycle( url: `https://cdn.vm7.io/artifacts/test/${file.id}/${file.filename}`, }; }); + const modelSelection = modelSelectionFromBody(body); options?.onQueuedMessageAppend?.({ content: body.prompt, hasTextContent: body.hasTextContent, attachments: attachFiles, clientMessageId, generationTemplate: body.generationTemplate, - modelSelection: body.modelSelection, + modelSelection, runOptions: body.runOptions, }); if (options?.appendGate) { @@ -694,6 +721,7 @@ export function mockChatLifecycle( }[]; hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; + model?: string; modelSelection?: ModelSelectionRequest | null; runOptions?: ChatRunOptionsRequest; computerUseHostId?: string | null; @@ -706,8 +734,9 @@ export function mockChatLifecycle( runPrompt = body.prompt; } rememberRunUserMessageId(body.clientMessageId); - options?.onRunCreate?.(body); - selectedModel = body.modelSelection?.selectedModel ?? selectedModel; + const modelSelection = modelSelectionFromBody(body); + options?.onRunCreate?.({ ...body, modelSelection }); + selectedModel = modelSelection?.selectedModel ?? selectedModel; codexServiceTier = body.runOptions?.codexServiceTier ?? null; runAssociated = true; createChatRun(threadId); @@ -812,10 +841,12 @@ export function mockChatLifecycle( context.mocks.api( chatThreadModelSelectionContract.update, ({ body, respond }) => { - selectedModel = body.modelSelection?.selectedModel ?? null; + const modelSelection = modelSelectionFromBody(body); + selectedModel = modelSelection?.selectedModel ?? null; codexServiceTier = body.codexServiceTier ?? null; options?.onModelSelectionUpdate?.({ - modelSelection: body.modelSelection, + model: body.model, + modelSelection, codexServiceTier: body.codexServiceTier, }); return respond(204); @@ -860,10 +891,15 @@ export function mockChatLifecycle( }); context.mocks.api(chatThreadsContract.create, ({ body, respond }) => { threadId = body.clientThreadId ?? threadId; - selectedModel = body.modelSelection.selectedModel; + const modelSelection = modelSelectionFromBody(body); + if (!modelSelection) { + throw new Error("Expected chat thread create to include model"); + } + selectedModel = modelSelection.selectedModel; options?.onThreadCreate?.({ clientThreadId: body.clientThreadId, - modelSelection: body.modelSelection, + model: body.model, + modelSelection, }); return respond(201, { id: threadId, @@ -883,7 +919,8 @@ export function mockChatLifecycle( options?.onSendRequest?.({ clientThreadId: body.clientThreadId, - modelSelection: body.modelSelection, + model: body.model, + modelSelection: modelSelectionFromBody(body), }); threadId = body.clientThreadId ?? threadId; const responseBody = hasActiveRun() diff --git a/turbo/packages/api-contracts/src/contracts/chat-threads.ts b/turbo/packages/api-contracts/src/contracts/chat-threads.ts index 056a960849b..3690b39952f 100644 --- a/turbo/packages/api-contracts/src/contracts/chat-threads.ts +++ b/turbo/packages/api-contracts/src/contracts/chat-threads.ts @@ -380,10 +380,22 @@ const chatThreadDraftSchema = z.object({ draftAttachments: z.array(persistedAttachmentSchema).nullable(), }); +const selectedModelRequestSchema = z + .string() + .min(1) + .superRefine((value, ctx) => { + if (!isSupportedRunModel(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid model selection", + }); + } + }); + /** - * Per-run model selection from the composer. Both fields are required when - * the object is present; pass `null` to clear the thread's override and fall - * back to the agent/org default; omit to leave the thread's override unchanged. + * Legacy per-run model selection from clients that still send an explicit + * provider pin. New clients should send `model` and let the API resolve the + * provider through org policy. */ const modelSelectionRequestSchema = z .object({ @@ -403,6 +415,74 @@ const modelSelectionRequestSchema = z } }); +function rejectConflictingModelFields( + value: { + readonly model?: string | null; + readonly modelSelection?: unknown; + }, + ctx: z.RefinementCtx, +): void { + if (value.model !== undefined && value.modelSelection !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["modelSelection"], + message: "Use model instead of modelSelection", + }); + } +} + +const chatThreadCreateBodySchema = z + .object({ + agentId: z.string().min(1), + clientThreadId: z.string().uuid().optional(), + eventId: chatThreadEventIdSchema.optional(), + /** + * Selected model id. The API resolves the effective model provider from + * org policy and available credentials. + */ + model: selectedModelRequestSchema.optional(), + /** + * Backward-compatible provider pin. Prefer `model`. + */ + modelSelection: modelSelectionRequestSchema.optional(), + title: z.string().optional(), + }) + .superRefine((value, ctx) => { + rejectConflictingModelFields(value, ctx); + if (value.model === undefined && value.modelSelection === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["model"], + message: "A model selection is required", + }); + } + }); + +const chatThreadModelSelectionUpdateBodySchema = z + .object({ + /** + * Selected model id, or null to clear the thread's selected model. + */ + model: selectedModelRequestSchema.nullable().optional(), + /** + * Backward-compatible provider pin, or null to clear the thread's selected + * model. Prefer `model`. + */ + modelSelection: modelSelectionRequestSchema.nullable().optional(), + codexServiceTier: codexServiceTierSchema.nullable().optional(), + eventId: chatThreadEventIdSchema.optional(), + }) + .superRefine((value, ctx) => { + rejectConflictingModelFields(value, ctx); + if (value.model === undefined && value.modelSelection === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["model"], + message: "A model selection is required", + }); + } + }); + const chatRunOptionsRequestSchema = z.object({ codexServiceTier: codexServiceTierSchema.optional(), }); @@ -460,13 +540,7 @@ export const chatThreadsContract = c.router({ method: "POST", path: "/api/zero/chat-threads", headers: authHeadersSchema, - body: z.object({ - agentId: z.string().min(1), - clientThreadId: z.string().uuid().optional(), - eventId: chatThreadEventIdSchema.optional(), - modelSelection: modelSelectionRequestSchema, - title: z.string().optional(), - }), + body: chatThreadCreateBodySchema, responses: { 201: z.object({ id: z.string(), @@ -768,11 +842,7 @@ export const chatThreadModelSelectionContract = c.router({ path: "/api/zero/chat-threads/:id/model-selection", headers: authHeadersSchema, pathParams: chatThreadIdPathParamsSchema, - body: z.object({ - modelSelection: modelSelectionRequestSchema.nullable(), - codexServiceTier: codexServiceTierSchema.nullable().optional(), - eventId: chatThreadEventIdSchema.optional(), - }), + body: chatThreadModelSelectionUpdateBodySchema, responses: { 204: c.noBody(), 400: apiErrorSchema, @@ -830,12 +900,15 @@ export const chatMessagesContract = c.router({ chatThreadSortEventId: chatThreadEventIdSchema.optional(), modelProvider: z.string().optional(), /** - * Per-run model override. This does not mutate the thread's selected - * model; thread model changes are persisted through - * `chatThreadModelSelectionContract.update`. + * Selected model id. The API resolves the effective provider from org + * policy and available credentials. + */ + model: selectedModelRequestSchema.optional(), + /** + * Backward-compatible per-run provider pin. Prefer `model`. * - * When omitted, the run resolves from the thread's persisted - * `selected_model`. + * When both fields are omitted, the run resolves from the thread's + * persisted `selected_model`. */ modelSelection: modelSelectionRequestSchema.nullable().optional(), runOptions: chatRunOptionsRequestSchema.optional(), @@ -869,6 +942,7 @@ export const chatMessagesContract = c.router({ chatThreadEventId: z.undefined().optional(), chatThreadSortEventId: z.undefined().optional(), modelProvider: z.undefined().optional(), + model: z.undefined().optional(), modelSelection: z.undefined().optional(), runOptions: z.undefined().optional(), generationTemplate: z.undefined().optional(), @@ -889,6 +963,7 @@ export const chatMessagesContract = c.router({ chatThreadEventId: z.undefined().optional(), chatThreadSortEventId: z.undefined().optional(), modelProvider: z.undefined().optional(), + model: z.undefined().optional(), modelSelection: z.undefined().optional(), runOptions: z.undefined().optional(), generationTemplate: z.undefined().optional(), From 4abdab3392eb5850c6d32f66fc2b60770cf78be7 Mon Sep 17 00:00:00 2001 From: Zero Date: Wed, 8 Jul 2026 14:01:04 +0800 Subject: [PATCH 02/10] fix: remove chat model selection request wrapper --- .../__tests__/chat-callbacks.bdd.test.ts | 20 +- .../routes/__tests__/chat-files.bdd.test.ts | 12 +- .../__tests__/chat-messages.bdd.test.ts | 191 ++++++++---------- .../routes/__tests__/chat-threads.bdd.test.ts | 64 +----- .../__tests__/helpers/api-bdd-chat-files.ts | 74 +++---- .../__tests__/run-lifecycle.bdd.test.ts | 9 +- .../routes/__tests__/webhooks-gmail.test.ts | 11 +- .../zero-chat-threads-model-selection.test.ts | 6 +- .../src/signals/routes/zero-chat-messages.ts | 23 +-- .../routes/zero-chat-threads-create.ts | 11 +- .../zero-chat-threads-model-selection.ts | 22 +- .../chat-page/chat-thread-data-source.ts | 9 +- .../chat-page/model-selection-request.ts | 13 -- ...hat-composer-models-and-templates.test.tsx | 17 +- .../__tests__/chat-inline-feedback.test.tsx | 10 +- .../__tests__/chat-run-queue.test.tsx | 10 +- .../zero-page/__tests__/chat-test-helpers.ts | 12 +- .../src/contracts/chat-threads.ts | 125 ++---------- .../api-contracts/src/contracts/index.ts | 2 - turbo/packages/core/src/index.ts | 2 - 20 files changed, 190 insertions(+), 453 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts index d997a6faeab..6be146720ad 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts @@ -54,8 +54,6 @@ function goalsClient() { return setupApp({ context })(zeroGoalsContract); } -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; const USER_ARTIFACTS_BUCKET = "test-user-artifacts"; const CHAT_CALLBACK_PRE_CREATE_TIMING_PREFIX = "api_dispatch_pre_create_zero_chat_callback_"; @@ -169,13 +167,8 @@ async function startChatRun( ? {} : { attachFiles: body.attachFiles }), ...(body.selectedModel === undefined - ? { modelProvider: "anthropic-api-key" } - : { - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: body.selectedModel, - }, - }), + ? { model: "claude-sonnet-4-6" } + : { model: body.selectedModel }), }, [201], ); @@ -2450,10 +2443,11 @@ describe("CHAT-02: auto-send across a model switch", () => { prompt: "And stringify?", }); const secondHeaders = await claimChatRun(runnerGroup, second.runId); - await chat.updateThreadModelSelection(actor, first.threadId, { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }); + await chat.updateThreadModelSelection( + actor, + first.threadId, + "claude-sonnet-4-6", + ); await queueChatMessage(actor, { agentId, threadId: first.threadId, diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-files.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-files.bdd.test.ts index bcf1a150a40..372b59700ce 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-files.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-files.bdd.test.ts @@ -33,8 +33,6 @@ const context = testContext(); const bdd = createBddApi(context); const api = createChatFilesBddApi(context); const authOrg = createAuthOrgAgentsBddApi(context); -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; describe("CHAT-01 chat thread lifecycle", () => { it("creates, mutates, searches, and deletes a thread through visible APIs", async () => { @@ -173,10 +171,7 @@ describe("CHAT-01 chat thread lifecycle", () => { }); await api.renameThread(owner, thread.id, "Pinned launch plan"); - await api.updateThreadModelSelection(owner, thread.id, { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4-mini", - }); + await api.updateThreadModelSelection(owner, thread.id, "gpt-5.4-mini"); await api.pinThread(owner, thread.id); const readEmpty = await api.markThreadRead(owner, thread.id); @@ -558,10 +553,7 @@ describe("CHAT-02 chat messages and visible validation", () => { { agentId: agent.agentId, prompt: "Persist the model selected at send time", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4-mini", - }, + model: "gpt-5.4-mini", }, [201], ); diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts index 1f0dbad754e..b2071493f1c 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts @@ -14,7 +14,6 @@ import { type AttachFile, type ChatRunOptionsRequest, type GenerationTemplateRequest, - type ModelSelectionRequest, type PagedChatMessage, } from "@vm0/api-contracts/contracts/chat-threads"; import type { @@ -70,8 +69,6 @@ const chatCallbacks = createChatCallbacksApi(context); const cu = createComputerUseBddApi(context); const misc = createMiscRoutesApi(context); const routeMocks = createZeroRouteMocks(context); -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; const API_DISPATCH_ZERO_WEB_CHAT_PRE_CREATE_ACTION_TYPES = [ "api_dispatch_pre_create_zero_web_chat_prepare_normal_send", "api_dispatch_pre_create_zero_web_chat_resolve_client_message", @@ -175,7 +172,6 @@ interface ChatRunSendBody { readonly clientThreadId?: string; readonly clientMessageId?: string; readonly model?: string; - readonly modelSelection?: ModelSelectionRequest; readonly runOptions?: ChatRunOptionsRequest; readonly generationTemplate?: GenerationTemplateRequest; readonly attachFiles?: readonly AttachFile[]; @@ -1652,7 +1648,7 @@ describe("CHAT-02: dispatch failure", () => { }); describe("CHAT-02: admission without spendable credits", () => { - it("blocks admission for provider-pinned sends through visible chat messages", async () => { + it("blocks admission for model-first sends through visible chat messages", async () => { const actor = bdd.user(); bdd.acceptAgentStorageWrites(); await bdd.setupOnboarding(actor, { @@ -1664,15 +1660,21 @@ describe("CHAT-02: admission without spendable credits", () => { const { providerId } = await upsertOrgModelProvider(actor, { type: "vm0", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "claude-sonnet-4-6", + isDefault: true, + defaultProviderType: "vm0", + credentialScope: "org", + modelProviderId: providerId, + }, + ]); const clientMessageId = randomUUID(); const sendBody: ChatRunSendBody = { agentId: agent.agentId, prompt: "blocked by suspended plan", - modelSelection: { - modelProviderId: providerId, - selectedModel: "claude-sonnet-4-6", - }, + model: "claude-sonnet-4-6", clientMessageId, }; const sent = await chat.requestSendMessage(actor, sendBody, [201]); @@ -1708,22 +1710,28 @@ describe("CHAT-02: admission without spendable credits", () => { }, 60_000); }); -describe("CHAT-02: explicit provider pins", () => { - it("routes explicit provider pins into the runner claim", async () => { +describe("CHAT-02: model-first provider policies", () => { + it("routes model policy providers into the runner claim", async () => { const { actor, agentId, runnerGroup } = await entitledChatActor(); chatCallbacks.failIfChatCallbackRouteIsFetched(); const { providerId: deepseekId } = await upsertOrgModelProvider(actor, { type: "deepseek-api-key", secret: "selected-deepseek-key", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "deepseek-v4-pro", + isDefault: true, + defaultProviderType: "deepseek-api-key", + credentialScope: "org", + modelProviderId: deepseekId, + }, + ]); const run = await sendChatRun(actor, { agentId, prompt: "run with the selected deepseek provider", - modelSelection: { - modelProviderId: deepseekId, - selectedModel: "deepseek-v4-pro", - }, + model: "deepseek-v4-pro", }); const { claim, sandboxHeaders } = await claimChatRun( @@ -1797,13 +1805,19 @@ describe("CHAT-02: explicit provider pins", () => { const { providerId: vm0Id } = await upsertOrgModelProvider(actor, { type: "vm0", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "claude-sonnet-4-6", + isDefault: true, + defaultProviderType: "vm0", + credentialScope: "org", + modelProviderId: vm0Id, + }, + ]); const vm0Send = await requestSendMessageRaw(actor, { agentId, prompt: "vm0-backed admission with spendable credits", - modelSelection: { - modelProviderId: vm0Id, - selectedModel: "claude-sonnet-4-6", - }, + model: "claude-sonnet-4-6", }); expect([201, 503]).toContain(vm0Send.status); if (vm0Send.status === 503) { @@ -1904,10 +1918,7 @@ describe("CHAT-02: explicit provider pins", () => { const invalidFastPatch = await chat.requestUpdateThreadModelSelection( actor, fast.threadId, - { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4", - }, + "gpt-5.4", [400], { codexServiceTier: "fast" }, ); @@ -1919,15 +1930,9 @@ describe("CHAT-02: explicit provider pins", () => { "fast", ); - await chat.updateThreadModelSelection( - actor, - fast.threadId, - { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4", - }, - { codexServiceTier: null }, - ); + await chat.updateThreadModelSelection(actor, fast.threadId, "gpt-5.4", { + codexServiceTier: null, + }); const updatedFastThread = await chat.readThread(actor, fast.threadId); expect(updatedFastThread).not.toHaveProperty("selectedModel"); expect(updatedFastThread.codexServiceTier).toBeNull(); @@ -1952,10 +1957,7 @@ describe("CHAT-02: explicit provider pins", () => { agentId, threadId: fast.threadId, prompt: "run codex standard", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.5", - }, + model: "gpt-5.5", }); expect( (await chat.readThread(actor, standard.threadId)).codexServiceTier, @@ -1976,10 +1978,7 @@ describe("CHAT-02: explicit provider pins", () => { agentId, prompt: "5.4 cannot fast", clientThreadId: rejectedThreadId, - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4", - }, + model: "gpt-5.4", runOptions: { codexServiceTier: "fast" }, }, [400], @@ -1998,14 +1997,20 @@ describe("CHAT-02: explicit provider pins", () => { type: "openrouter-api-key", secret: "test-openrouter-key", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "claude-opus-4-7", + isDefault: true, + defaultProviderType: "openrouter-api-key", + credentialScope: "org", + modelProviderId: providerId, + }, + ]); const run = await sendChatRun(actor, { agentId, prompt: "run with the selected openrouter provider", - modelSelection: { - modelProviderId: providerId, - selectedModel: "claude-opus-4-7", - }, + model: "claude-opus-4-7", }); const { claim, sandboxHeaders } = await claimChatRun( @@ -2110,14 +2115,20 @@ describe("CHAT-02: explicit provider pins", () => { const { providerId } = await upsertOrgModelProvider(actor, { type: "vm0", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "kimi-k2.7-code", + isDefault: true, + defaultProviderType: "vm0", + credentialScope: "org", + modelProviderId: providerId, + }, + ]); const run = await sendChatRun(actor, { agentId, prompt: "run with the selected vm0 kimi provider", - modelSelection: { - modelProviderId: providerId, - selectedModel: "kimi-k2.7-code", - }, + model: "kimi-k2.7-code", }); runId = run.runId; @@ -2162,14 +2173,20 @@ describe("CHAT-02: explicit provider pins", () => { const { providerId } = await upsertOrgModelProvider(actor, { type: "vm0", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "glm-5.2", + isDefault: true, + defaultProviderType: "vm0", + credentialScope: "org", + modelProviderId: providerId, + }, + ]); const run = await sendChatRun(actor, { agentId, prompt: "run with the selected vm0 provider", - modelSelection: { - modelProviderId: providerId, - selectedModel: "glm-5.2", - }, + model: "glm-5.2", }); const { claim, sandboxHeaders } = await claimChatRun( @@ -2222,15 +2239,21 @@ describe("CHAT-02: explicit provider pins", () => { type: "openrouter-api-key", secret: "test-openrouter-key", }); + await api.updateOrgModelPolicies(actor, [ + { + model: "claude-opus-4-7", + isDefault: true, + defaultProviderType: "openrouter-api-key", + credentialScope: "org", + modelProviderId: providerId, + }, + ]); await overwriteOrgModelProviderSecret(orgId, "OPENROUTER_API_KEY", " "); const run = await sendChatRun(actor, { agentId, prompt: "run with a legacy blank openrouter provider", - modelSelection: { - modelProviderId: providerId, - selectedModel: "claude-opus-4-7", - }, + model: "claude-opus-4-7", }); const { claim, sandboxHeaders } = await claimChatRun( runnerGroup, @@ -2286,10 +2309,7 @@ describe("CHAT-02: run-level model overrides", () => { const first = await sendChatRun(actor, { agentId, prompt: firstPrompt, - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-opus-4-6", - }, + model: "claude-opus-4-6", }); const firstClaim = await claimChatRun(runnerGroup, first.runId); expect(claimEnvironment(firstClaim.claim).ANTHROPIC_MODEL).toBe( @@ -2319,10 +2339,7 @@ describe("CHAT-02: run-level model overrides", () => { agentId, threadId: first.threadId, prompt: "switch to sonnet", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }, + model: "claude-sonnet-4-6", }); const secondRun = await api.readRun(actor, second.runId); const appended = secondRun.appendSystemPrompt ?? ""; @@ -2368,10 +2385,7 @@ describe("CHAT-02: run-level model overrides", () => { const first = await sendChatRun(actor, { agentId, prompt: "pin sonnet model-first", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }, + model: "claude-sonnet-4-6", }); const firstClaim = await claimChatRun(runnerGroup, first.runId); chatCallbacks.mockChatOutputEvents([]); @@ -2426,34 +2440,7 @@ describe("CHAT-02: run-level model overrides", () => { displayName: "Invalid model selection agent", }); - // A provider id from another workspace is unknown here. - const outsider = bdd.user(); - const foreign = await upsertOrgModelProvider(outsider, { - type: "anthropic-api-key", - secret: "foreign-org-key", - }); - const foreignThreadId = randomUUID(); - const foreignPin = await chat.requestSendMessage( - actor, - { - agentId: agent.agentId, - prompt: "use a foreign provider", - clientThreadId: foreignThreadId, - modelSelection: { - modelProviderId: foreign.providerId, - selectedModel: "claude-sonnet-4-6", - }, - }, - [400], - ); - expectApiError(foreignPin.body); - expect(foreignPin.body.error.message).toBe( - "Unknown model provider for this workspace", - ); - await chat.requestReadThread(actor, foreignThreadId, [404]); - - // A vm0 provider pin only accepts supported run models. - const vm0Provider = await upsertOrgModelProvider(actor, { type: "vm0" }); + // Chat send only accepts supported run models. const vm0ThreadId = randomUUID(); const invalidVm0Model = await chat.requestSendMessage( actor, @@ -2461,10 +2448,7 @@ describe("CHAT-02: run-level model overrides", () => { agentId: agent.agentId, prompt: "use an unsupported vm0 model", clientThreadId: vm0ThreadId, - modelSelection: { - modelProviderId: vm0Provider.providerId, - selectedModel: "codex", - }, + model: "codex", }, [400], ); @@ -2484,17 +2468,14 @@ describe("CHAT-02: run-level model overrides", () => { agentId: agent.agentId, prompt: `removed ${selectedModel}`, clientThreadId: removedThreadId, - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel, - }, + model: selectedModel, }, [400], ); expectApiError(removed.body); expect(removed.body.error).toMatchObject({ code: "BAD_REQUEST", - message: "modelSelection.selectedModel: Invalid model selection", + message: "model: Invalid model selection", }); await chat.requestReadThread(actor, removedThreadId, [404]); } diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-threads.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-threads.bdd.test.ts index e203af535fe..713bacb8e3f 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-threads.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-threads.bdd.test.ts @@ -76,8 +76,6 @@ const cu = createComputerUseBddApi(context); const connectorsApi = createConnectorBddApi(context); const authOrg = createAuthOrgAgentsBddApi(context); const routeMocks = createZeroRouteMocks(context); -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; const CHAT_THREAD_SNAPSHOT_CRON_SECRET = "chat-thread-snapshot-cron-secret"; type AssistantMessage = Extract; @@ -166,10 +164,7 @@ async function sendChatRun( readonly prompt: string; readonly threadId?: string; readonly chatThreadSortEventId?: string; - readonly modelSelection?: { - readonly modelProviderId: string; - readonly selectedModel: string; - }; + readonly model?: string; }, ): Promise<{ readonly runId: string; readonly threadId: string }> { const sent = await chat.requestSendMessage(actor, body, [201]); @@ -520,10 +515,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { await chat.updateThreadModelSelection( actor, thread.id, - { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }, + "claude-sonnet-4-6", { eventId: modelSelectionEventId }, ); @@ -692,10 +684,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { await chat.updateThreadModelSelection( actor, liveThread.id, - { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }, + "claude-sonnet-4-6", { eventId: modelSelectionEventId }, ); chat.mockObjectStorageObjectsExist(); @@ -837,10 +826,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { const run = await sendChatRun(actor, { agentId, prompt: "pin the first run model", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }, + model: "claude-sonnet-4-6", }); let detail = await chat.readThread(actor, run.threadId); @@ -859,10 +845,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { const invalidSelection = await chat.requestUpdateThreadModelSelection( actor, run.threadId, - { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "not-a-supported-model", - }, + "not-a-supported-model", [400], ); expectApiError(invalidSelection.body); @@ -889,10 +872,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { const thread = await chat.createThread(actor, { agentId, - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }, + model: "claude-sonnet-4-6", title: "limited free model pin", }); for (const selectedModel of [ @@ -904,10 +884,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { const restrictedSelection = await chat.requestUpdateThreadModelSelection( actor, thread.id, - { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel, - }, + selectedModel, [402], ); expectApiError(restrictedSelection.body); @@ -922,32 +899,7 @@ describe("CHAT-01 thread detail, create, and delete cascades", () => { ).resolves.not.toHaveProperty("selectedModel"); } - const { providerId: byokProviderId } = await api.createOrgModelProvider( - actor, - { - type: "anthropic-api-key", - secret: "sk-ant-limited-free-byok", - }, - ); - const byokSelection = await chat.requestUpdateThreadModelSelection( - actor, - thread.id, - { - modelProviderId: byokProviderId, - selectedModel: "MiniMax-M3", - }, - [402], - ); - expectApiError(byokSelection.body); - expect(byokSelection.body.error.code).toBe("INSUFFICIENT_CREDITS"); - await expect(chat.readThread(actor, thread.id)).resolves.not.toHaveProperty( - "modelProviderId", - ); - - await chat.updateThreadModelSelection(actor, thread.id, { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "MiniMax-M3", - }); + await chat.updateThreadModelSelection(actor, thread.id, "MiniMax-M3"); const detail = await chat.readThread(actor, thread.id); expect(detail).not.toHaveProperty("selectedModel"); }, 90_000); diff --git a/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts b/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts index a8c37c4ef30..0f87b2ad5b1 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts @@ -27,7 +27,6 @@ import { type ChatRunOptionsRequest, type CodexServiceTier, type GenerationTemplateRequest, - type ModelSelectionRequest, type PagedChatMessage, type PersistedAttachment, } from "@vm0/api-contracts/contracts/chat-threads"; @@ -98,16 +97,10 @@ import { createZeroRouteMocks } from "./zero-route-test"; export { hostedTextFile } from "./api-bdd-host-files"; export { storageTextFile } from "./api-bdd-storage-files"; -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; - type ArtifactsListRequestQuery = Partial; -function defaultCreateThreadModelSelection(): ModelSelectionRequest { - return { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-4-6", - }; +function defaultCreateThreadModel(): string { + return "claude-sonnet-4-6"; } type StorageType = "volume" | "artifact"; @@ -175,9 +168,7 @@ type BddSendMessageBody = readonly prompt: string; readonly threadId?: string; readonly clientThreadId?: string; - readonly modelProvider?: string; readonly model?: string; - readonly modelSelection?: ModelSelectionRequest | null; readonly runOptions?: ChatRunOptionsRequest; readonly generationTemplate?: GenerationTemplateRequest; readonly hasTextContent?: boolean; @@ -461,17 +452,19 @@ export function createChatFilesBddApi(context: TestContext) { readonly clientThreadId?: string; readonly eventId?: string; readonly model?: string; - readonly modelSelection?: ModelSelectionRequest; }, ): Promise<{ readonly id: string; readonly title: string | null }> { const response = await accept( threadsClient().create({ headers: authenticate(context, actor), body: { - ...body, - ...(body.model === undefined && body.modelSelection === undefined - ? { model: defaultCreateThreadModelSelection().selectedModel } - : {}), + agentId: body.agentId, + ...(body.title === undefined ? {} : { title: body.title }), + ...(body.clientThreadId === undefined + ? {} + : { clientThreadId: body.clientThreadId }), + ...(body.eventId === undefined ? {} : { eventId: body.eventId }), + model: body.model ?? defaultCreateThreadModel(), }, }), [201], @@ -487,7 +480,6 @@ export function createChatFilesBddApi(context: TestContext) { readonly clientThreadId?: string; readonly eventId?: string; readonly model?: string; - readonly modelSelection?: ModelSelectionRequest; }, statuses: readonly (201 | 401 | 402 | 404)[], ) { @@ -495,10 +487,13 @@ export function createChatFilesBddApi(context: TestContext) { threadsClient().create({ headers: authenticate(context, actor), body: { - ...body, - ...(body.model === undefined && body.modelSelection === undefined - ? { model: defaultCreateThreadModelSelection().selectedModel } - : {}), + agentId: body.agentId, + ...(body.title === undefined ? {} : { title: body.title }), + ...(body.clientThreadId === undefined + ? {} + : { clientThreadId: body.clientThreadId }), + ...(body.eventId === undefined ? {} : { eventId: body.eventId }), + model: body.model ?? defaultCreateThreadModel(), }, }), statuses, @@ -849,7 +844,7 @@ export function createChatFilesBddApi(context: TestContext) { async updateThreadModelSelection( actor: ApiTestUser, threadId: string, - modelSelection: ModelSelectionRequest | null, + model: string | null, options?: { readonly codexServiceTier?: CodexServiceTier | null; readonly eventId?: string; @@ -860,12 +855,7 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), params: { id: threadId }, body: { - ...(modelSelection === null - ? { model: null } - : modelSelection?.modelProviderId === - MODEL_FIRST_SELECTION_PROVIDER_ID - ? { model: modelSelection.selectedModel } - : { modelSelection }), + model, codexServiceTier: options?.codexServiceTier, eventId: options?.eventId, }, @@ -877,7 +867,7 @@ export function createChatFilesBddApi(context: TestContext) { async requestUpdateThreadModelSelection( actor: ApiTestUser | null, threadId: string, - modelSelection: ModelSelectionRequest | null, + model: string | null, statuses: readonly (204 | 400 | 401 | 402 | 404)[], options?: { readonly codexServiceTier?: CodexServiceTier | null; @@ -889,12 +879,7 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), params: { id: threadId }, body: { - ...(modelSelection === null - ? { model: null } - : modelSelection?.modelProviderId === - MODEL_FIRST_SELECTION_PROVIDER_ID - ? { model: modelSelection.selectedModel } - : { modelSelection }), + model, codexServiceTier: options?.codexServiceTier, eventId: options?.eventId, }, @@ -1215,11 +1200,10 @@ export function createChatFilesBddApi(context: TestContext) { "prompt" in body ? (() => { const defaultModel = - body.threadId === undefined && - body.model === undefined && - body.modelSelection === undefined - ? defaultCreateThreadModelSelection().selectedModel + body.threadId === undefined && body.model === undefined + ? defaultCreateThreadModel() : undefined; + const selectedModel = body.model ?? defaultModel; return { agentId: body.agentId, prompt: body.prompt, @@ -1229,17 +1213,9 @@ export function createChatFilesBddApi(context: TestContext) { ...(body.clientThreadId === undefined ? {} : { clientThreadId: body.clientThreadId }), - ...(body.modelProvider === undefined - ? {} - : { modelProvider: body.modelProvider }), - ...(body.model === undefined - ? defaultModel === undefined - ? {} - : { model: defaultModel } - : { model: body.model }), - ...(body.modelSelection === undefined + ...(selectedModel === undefined ? {} - : { modelSelection: body.modelSelection }), + : { model: selectedModel }), ...(body.runOptions === undefined ? {} : { runOptions: body.runOptions }), diff --git a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts index 4c1dfa1eb62..d4070c278b7 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts @@ -68,10 +68,6 @@ const context = testContext(); const ASSISTANT_MESSAGE_ID_NAMESPACE = "bfec4fb6-d5b8-43e4-a72a-9f58f87d7e01"; const TEST_DATA_KEY = Buffer.from("0123456789abcdef0123456789abcdef", "utf8"); -// Sentinel provider id for model-first thread selections (the wire-protocol -// value the chat composer sends when picking a model instead of a provider). -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; const API_DISPATCH_QUEUE_PERSISTENCE_ACTION_TYPES = [ "api_dispatch_persist_custom_connector_auth_refs", "api_dispatch_insert_runner_job_queue", @@ -3066,10 +3062,7 @@ describe("RUN-02: model provider selection and vm0 admission", () => { agentId: agent.agentId, threadId: thread.id, prompt: "run on the pinned member provider", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4-mini", - }, + model: "gpt-5.4-mini", }, [201], ); diff --git a/turbo/apps/api/src/signals/routes/__tests__/webhooks-gmail.test.ts b/turbo/apps/api/src/signals/routes/__tests__/webhooks-gmail.test.ts index 989800f3c26..2266bbc1040 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/webhooks-gmail.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/webhooks-gmail.test.ts @@ -42,8 +42,6 @@ const miscApi = createMiscRoutesApi(context); const webhooksApi = createWebhookCallbackApi(context); const WORKFLOW_NAME = "gmail-webhook-workflow"; -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; const GMAIL_TOPIC_NAME = "projects/vm0-ai-488909/topics/gmail-events"; const GMAIL_AUDIENCE = "https://api.vm0.ai/api/webhooks/gmail"; const GMAIL_PUSH_SERVICE_ACCOUNT = @@ -429,10 +427,11 @@ async function configureTriggerThreadModel( actor: ApiTestUser, chatThreadId: string, ): Promise { - await chatApi.updateThreadModelSelection(actor, chatThreadId, { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: GMAIL_WORKSPACE_MODEL, - }); + await chatApi.updateThreadModelSelection( + actor, + chatThreadId, + GMAIL_WORKSPACE_MODEL, + ); } async function grantVisibleCredits( diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts index 3bd6d6535d9..c8bf20409a6 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-chat-threads-model-selection.test.ts @@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto"; import { chatThreadMetadataContract, chatThreadModelSelectionContract, - MODEL_FIRST_SELECTION_PROVIDER_ID, } from "@vm0/api-contracts/contracts/chat-threads"; import type { ZeroCapability } from "@vm0/api-contracts/contracts/composes"; import { createStore } from "ccstate"; @@ -131,10 +130,7 @@ describe("POST /api/zero/chat-threads/:id/model-selection", () => { headers: { authorization: `Bearer ${token}` }, params: { id: fixture.threadId }, body: { - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-5", - }, + model: "claude-sonnet-5", }, }), [403], diff --git a/turbo/apps/api/src/signals/routes/zero-chat-messages.ts b/turbo/apps/api/src/signals/routes/zero-chat-messages.ts index e493244147f..6ae4e3a9316 100644 --- a/turbo/apps/api/src/signals/routes/zero-chat-messages.ts +++ b/turbo/apps/api/src/signals/routes/zero-chat-messages.ts @@ -105,7 +105,6 @@ interface NormalSendBody { readonly clientThreadId?: string; readonly chatThreadEventId?: string; readonly chatThreadSortEventId?: string; - readonly modelProvider?: string; readonly model?: string; readonly modelSelection?: { readonly modelProviderId: string; @@ -474,21 +473,13 @@ function modelFirstSelection( }; } -function normalizeNormalSendBody( - body: NormalSendBody, -): +function normalizeNormalSendBody(body: NormalSendBody): | { readonly ok: true; readonly data: NormalSendBody } | { readonly ok: false; readonly response: ReturnType; } { - if (body.model !== undefined && body.modelSelection !== undefined) { - return { - ok: false, - response: badRequestMessage("Use model instead of modelSelection"), - }; - } - if (body.model === undefined || body.modelSelection !== undefined) { + if (body.model === undefined) { return { ok: true, data: body }; } return { @@ -1288,7 +1279,7 @@ async function validateCodexServiceTierBeforeThread(params: { orgId: params.orgId, userId: params.userId, modelPin, - requestedModelProvider: requestedModelProviderFor(params.body), + requestedModelProvider: undefined, }); const codexServiceTierError = validateCodexServiceTier({ body: params.body, @@ -2681,12 +2672,6 @@ async function resolveTimedRunModelPin( ); } -function requestedModelProviderFor(body: NormalSendBody): string | undefined { - return body.modelProvider && body.modelProvider !== "default" - ? body.modelProvider - : undefined; -} - async function resolveTimedProviderAdmission(params: { readonly args: NormalSendArgs; readonly prepared: PreparedNormalSend; @@ -2858,7 +2843,7 @@ const createNormalChatRun$ = command( args, prepared, modelPin, - requestedModelProvider: requestedModelProviderFor(args.body), + requestedModelProvider: undefined, }); signal.throwIfAborted(); if (providerAdmission.error) { diff --git a/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts b/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts index 20011190136..54653a42398 100644 --- a/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts +++ b/turbo/apps/api/src/signals/routes/zero-chat-threads-create.ts @@ -9,7 +9,7 @@ import { authRoute } from "../auth/auth-route"; import { bodyResultOf } from "../context/request"; import { writeDb$ } from "../external/db"; import { publishThreadListChanged } from "../external/realtime"; -import { badRequestMessage, notFound } from "../../lib/error"; +import { notFound } from "../../lib/error"; import { createChatThread$ } from "../services/zero-chat-thread.service"; import { zeroComposeExists } from "../services/zero-compose-data.service"; import { resolveModelSelectionPin } from "../services/zero-model-selection.service"; @@ -43,18 +43,11 @@ const createInner$ = command(async ({ get, set }, signal: AbortSignal) => { return notFound("Agent not found"); } - const modelSelection = - body.data.modelSelection ?? - (body.data.model ? modelFirstSelection(body.data.model) : undefined); - if (!modelSelection) { - return badRequestMessage("A model selection is required"); - } - const pin = await resolveModelSelectionPin({ db: set(writeDb$), orgId: auth.orgId, userId: auth.userId, - modelSelection, + modelSelection: modelFirstSelection(body.data.model), }); signal.throwIfAborted(); if ("status" in pin) { diff --git a/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts b/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts index 669dc1ef46c..61151863a1d 100644 --- a/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts +++ b/turbo/apps/api/src/signals/routes/zero-chat-threads-model-selection.ts @@ -35,22 +35,6 @@ function modelFirstSelection(selectedModel: string) { }; } -function modelSelectionFromBody(body: { - readonly model?: string | null; - readonly modelSelection?: { - readonly modelProviderId: string; - readonly selectedModel: string; - } | null; -}) { - if (body.modelSelection !== undefined) { - return body.modelSelection; - } - if (body.model === undefined) { - return undefined; - } - return body.model === null ? null : modelFirstSelection(body.model); -} - function isCodexFastServiceTierModel( model: string | null | undefined, ): boolean { @@ -112,10 +96,8 @@ const updateModelSelectionInner$ = command( } const writeDb = set(writeDb$); - const modelSelection = modelSelectionFromBody(body.data); - if (modelSelection === undefined) { - return badRequestMessage("A model selection is required"); - } + const modelSelection = + body.data.model === null ? null : modelFirstSelection(body.data.model); const pin = modelSelection ? await resolveModelSelectionPin({ db: writeDb, diff --git a/turbo/apps/platform/src/signals/chat-page/chat-thread-data-source.ts b/turbo/apps/platform/src/signals/chat-page/chat-thread-data-source.ts index 194b30bf47d..f8f60ce27ba 100644 --- a/turbo/apps/platform/src/signals/chat-page/chat-thread-data-source.ts +++ b/turbo/apps/platform/src/signals/chat-page/chat-thread-data-source.ts @@ -3,7 +3,6 @@ import type { ChatRunOptionsRequest, CodexServiceTier, ChatThreadDraft, - ModelSelectionRequest, GenerationTemplateRequest, PagedChatMessage, PersistedAttachment, @@ -36,9 +35,11 @@ export interface PatchDraftArgs { export interface PatchModelSelectionArgs { threadId: string; - modelSelection: - | (ModelSelectionRequest & { codexServiceTier?: CodexServiceTier }) - | null; + modelSelection: { + readonly modelProviderId: string; + readonly selectedModel: string; + readonly codexServiceTier?: CodexServiceTier; + } | null; } export interface PatchComputerUseHostArgs { diff --git a/turbo/apps/platform/src/signals/chat-page/model-selection-request.ts b/turbo/apps/platform/src/signals/chat-page/model-selection-request.ts index 789c8acffef..de54c1b7ab5 100644 --- a/turbo/apps/platform/src/signals/chat-page/model-selection-request.ts +++ b/turbo/apps/platform/src/signals/chat-page/model-selection-request.ts @@ -1,22 +1,9 @@ import type { ChatRunOptionsRequest, CodexServiceTier, - ModelSelectionRequest, } from "@vm0/api-contracts/contracts/chat-threads"; import type { ModelProviderSelection } from "../../views/zero-page/components/model-provider-picker.tsx"; -export function modelSelectionRequestFromSelection( - value: ModelProviderSelection | null, -): ModelSelectionRequest | null { - if (!value) { - return null; - } - return { - modelProviderId: value.modelProviderId, - selectedModel: value.selectedModel, - }; -} - export function runOptionsFromModelProviderSelection( value: ModelProviderSelection | null, codexFastModeEnabled: boolean, diff --git a/turbo/apps/platform/src/views/zero-page/__tests__/chat-composer-models-and-templates.test.tsx b/turbo/apps/platform/src/views/zero-page/__tests__/chat-composer-models-and-templates.test.tsx index e1441611965..5c3c3862ee3 100644 --- a/turbo/apps/platform/src/views/zero-page/__tests__/chat-composer-models-and-templates.test.tsx +++ b/turbo/apps/platform/src/views/zero-page/__tests__/chat-composer-models-and-templates.test.tsx @@ -1109,19 +1109,13 @@ describe("chat composer models", () => { }); let sentBody: | { - modelSelection?: { - modelProviderId: string; - selectedModel: string; - } | null; + model?: string; runOptions?: { codexServiceTier?: "fast" }; } | undefined; let createdBody: | { - modelSelection: { - modelProviderId: string; - selectedModel: string; - }; + model?: string; } | undefined; @@ -1173,11 +1167,8 @@ describe("chat composer models", () => { ); await waitFor(() => { - expect(createdBody?.modelSelection).toStrictEqual({ - modelProviderId: "00000000-0000-4000-8000-000000000000", - selectedModel: "gpt-5.5", - }); - expect(sentBody?.modelSelection).toBeUndefined(); + expect(createdBody?.model).toBe("gpt-5.5"); + expect(sentBody?.model).toBeUndefined(); expect(sentBody?.runOptions).toStrictEqual({ codexServiceTier: "fast", }); diff --git a/turbo/apps/platform/src/views/zero-page/__tests__/chat-inline-feedback.test.tsx b/turbo/apps/platform/src/views/zero-page/__tests__/chat-inline-feedback.test.tsx index a329862e47a..f0be63d574f 100644 --- a/turbo/apps/platform/src/views/zero-page/__tests__/chat-inline-feedback.test.tsx +++ b/turbo/apps/platform/src/views/zero-page/__tests__/chat-inline-feedback.test.tsx @@ -1,10 +1,7 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import type { - GenerationTemplateRequest, - ModelSelectionRequest, -} from "@vm0/api-contracts/contracts/chat-threads"; +import type { GenerationTemplateRequest } from "@vm0/api-contracts/contracts/chat-threads"; import { PRESENTATION_TEMPLATE_PICKER_ITEMS } from "@vm0/core"; import { toast } from "@vm0/ui/components/ui/sonner"; import { testContext } from "../../../signals/__tests__/test-helpers.ts"; @@ -20,6 +17,11 @@ const context = testContext(); const FEEDBACK_THREAD_ID = "b0000000-0000-4000-a000-000000000703"; +interface ModelSelectionRequest { + readonly modelProviderId: string; + readonly selectedModel: string; +} + interface RunCreateCapture { prompt?: string; attachFiles?: { diff --git a/turbo/apps/platform/src/views/zero-page/__tests__/chat-run-queue.test.tsx b/turbo/apps/platform/src/views/zero-page/__tests__/chat-run-queue.test.tsx index 0e1fca9d4b5..9420b969c39 100644 --- a/turbo/apps/platform/src/views/zero-page/__tests__/chat-run-queue.test.tsx +++ b/turbo/apps/platform/src/views/zero-page/__tests__/chat-run-queue.test.tsx @@ -1,10 +1,7 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import type { - ModelSelectionRequest, - PersistedAttachment, -} from "@vm0/api-contracts/contracts/chat-threads"; +import type { PersistedAttachment } from "@vm0/api-contracts/contracts/chat-threads"; import { testContext } from "../../../signals/__tests__/test-helpers.ts"; import { click, @@ -27,6 +24,11 @@ const THREAD_ID = "b0000000-0000-4000-a000-000000000901"; const CHAT_PATH = `/chats/${THREAD_ID}`; const AGENT_CHAT_PATH = `/agents/${AGENT_ID}/chat`; +interface ModelSelectionRequest { + readonly modelProviderId: string; + readonly selectedModel: string; +} + interface QueuedMessageCapture { content?: string; hasTextContent?: boolean; diff --git a/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts b/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts index 4e76b801cde..623a3755f84 100644 --- a/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts +++ b/turbo/apps/platform/src/views/zero-page/__tests__/chat-test-helpers.ts @@ -19,7 +19,6 @@ import { type ChatRunOptionsRequest, type CodexServiceTier, type GenerationTemplateRequest, - type ModelSelectionRequest, type PagedChatMessage, type PersistedAttachment, } from "@vm0/api-contracts/contracts/chat-threads"; @@ -45,6 +44,11 @@ const DEFAULT_AGENT_ID = "c0000000-0000-4000-a000-000000000001"; const MOCK_RUN_ID = "d0000000-0000-4000-a000-000000000001"; const SUB_AGENT_ID = "a1111111-0000-4000-a000-000000000001"; +interface ModelSelectionRequest { + readonly modelProviderId: string; + readonly selectedModel: string; +} + function modelFirstSelection(selectedModel: string): ModelSelectionRequest { return { modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, @@ -54,11 +58,7 @@ function modelFirstSelection(selectedModel: string): ModelSelectionRequest { function modelSelectionFromBody(body: { readonly model?: string | null; - readonly modelSelection?: ModelSelectionRequest | null; }): ModelSelectionRequest | null | undefined { - if (body.modelSelection !== undefined) { - return body.modelSelection; - } if (body.model === undefined) { return undefined; } @@ -675,7 +675,6 @@ export function mockChatLifecycle( hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; model?: string; - modelSelection?: ModelSelectionRequest | null; runOptions?: ChatRunOptionsRequest; }) => { const clientMessageId = body.clientMessageId ?? crypto.randomUUID(); @@ -722,7 +721,6 @@ export function mockChatLifecycle( hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; model?: string; - modelSelection?: ModelSelectionRequest | null; runOptions?: ChatRunOptionsRequest; computerUseHostId?: string | null; revokesMessageId?: string; diff --git a/turbo/packages/api-contracts/src/contracts/chat-threads.ts b/turbo/packages/api-contracts/src/contracts/chat-threads.ts index 3690b39952f..6f9376abe80 100644 --- a/turbo/packages/api-contracts/src/contracts/chat-threads.ts +++ b/turbo/packages/api-contracts/src/contracts/chat-threads.ts @@ -392,96 +392,26 @@ const selectedModelRequestSchema = z } }); -/** - * Legacy per-run model selection from clients that still send an explicit - * provider pin. New clients should send `model` and let the API resolve the - * provider through org policy. - */ -const modelSelectionRequestSchema = z - .object({ - modelProviderId: z.string().uuid(), - selectedModel: z.string().min(1), - }) - .superRefine((value, ctx) => { - if ( - value.modelProviderId === MODEL_FIRST_SELECTION_PROVIDER_ID && - !isSupportedRunModel(value.selectedModel) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["selectedModel"], - message: "Invalid model selection", - }); - } - }); - -function rejectConflictingModelFields( - value: { - readonly model?: string | null; - readonly modelSelection?: unknown; - }, - ctx: z.RefinementCtx, -): void { - if (value.model !== undefined && value.modelSelection !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["modelSelection"], - message: "Use model instead of modelSelection", - }); - } -} - -const chatThreadCreateBodySchema = z - .object({ - agentId: z.string().min(1), - clientThreadId: z.string().uuid().optional(), - eventId: chatThreadEventIdSchema.optional(), - /** - * Selected model id. The API resolves the effective model provider from - * org policy and available credentials. - */ - model: selectedModelRequestSchema.optional(), - /** - * Backward-compatible provider pin. Prefer `model`. - */ - modelSelection: modelSelectionRequestSchema.optional(), - title: z.string().optional(), - }) - .superRefine((value, ctx) => { - rejectConflictingModelFields(value, ctx); - if (value.model === undefined && value.modelSelection === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["model"], - message: "A model selection is required", - }); - } - }); +const chatThreadCreateBodySchema = z.object({ + agentId: z.string().min(1), + clientThreadId: z.string().uuid().optional(), + eventId: chatThreadEventIdSchema.optional(), + /** + * Selected model id. The API resolves the effective model provider from org + * policy and available credentials. + */ + model: selectedModelRequestSchema, + title: z.string().optional(), +}); -const chatThreadModelSelectionUpdateBodySchema = z - .object({ - /** - * Selected model id, or null to clear the thread's selected model. - */ - model: selectedModelRequestSchema.nullable().optional(), - /** - * Backward-compatible provider pin, or null to clear the thread's selected - * model. Prefer `model`. - */ - modelSelection: modelSelectionRequestSchema.nullable().optional(), - codexServiceTier: codexServiceTierSchema.nullable().optional(), - eventId: chatThreadEventIdSchema.optional(), - }) - .superRefine((value, ctx) => { - rejectConflictingModelFields(value, ctx); - if (value.model === undefined && value.modelSelection === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["model"], - message: "A model selection is required", - }); - } - }); +const chatThreadModelSelectionUpdateBodySchema = z.object({ + /** + * Selected model id, or null to clear the thread's selected model. + */ + model: selectedModelRequestSchema.nullable(), + codexServiceTier: codexServiceTierSchema.nullable().optional(), + eventId: chatThreadEventIdSchema.optional(), +}); const chatRunOptionsRequestSchema = z.object({ codexServiceTier: codexServiceTierSchema.optional(), @@ -898,19 +828,12 @@ export const chatMessagesContract = c.router({ // Client-generated UUID for the sort touch created by direct user sends. // Lets event-sourced clients reconcile optimistic sidebar recency by id. chatThreadSortEventId: chatThreadEventIdSchema.optional(), - modelProvider: z.string().optional(), /** * Selected model id. The API resolves the effective provider from org - * policy and available credentials. + * policy and available credentials. Existing threads may omit it to + * reuse the thread's persisted model. */ model: selectedModelRequestSchema.optional(), - /** - * Backward-compatible per-run provider pin. Prefer `model`. - * - * When both fields are omitted, the run resolves from the thread's - * persisted `selected_model`. - */ - modelSelection: modelSelectionRequestSchema.nullable().optional(), runOptions: chatRunOptionsRequestSchema.optional(), generationTemplate: generationTemplateRequestSchema.optional(), computerUseHostId: z.string().uuid().nullable().optional(), @@ -941,9 +864,7 @@ export const chatMessagesContract = c.router({ clientThreadId: z.undefined().optional(), chatThreadEventId: z.undefined().optional(), chatThreadSortEventId: z.undefined().optional(), - modelProvider: z.undefined().optional(), model: z.undefined().optional(), - modelSelection: z.undefined().optional(), runOptions: z.undefined().optional(), generationTemplate: z.undefined().optional(), computerUseHostId: z.undefined().optional(), @@ -962,9 +883,7 @@ export const chatMessagesContract = c.router({ clientThreadId: z.undefined().optional(), chatThreadEventId: z.undefined().optional(), chatThreadSortEventId: z.undefined().optional(), - modelProvider: z.undefined().optional(), model: z.undefined().optional(), - modelSelection: z.undefined().optional(), runOptions: z.undefined().optional(), generationTemplate: z.undefined().optional(), computerUseHostId: z.undefined().optional(), @@ -1261,7 +1180,6 @@ export { chatThreadDetailSchema, chatThreadMetadataSchema, chatThreadDraftSchema, - modelSelectionRequestSchema, chatRunOptionsRequestSchema, generationTemplateRequestSchema, presentationGenerationTemplateRequestSchema, @@ -1283,7 +1201,6 @@ export { htmlArtifactEditSnapshotSchema, }; -export type ModelSelectionRequest = z.infer; export type CodexServiceTier = z.infer; export type ChatRunOptionsRequest = z.infer; export type GenerationTemplateRequest = z.infer< diff --git a/turbo/packages/api-contracts/src/contracts/index.ts b/turbo/packages/api-contracts/src/contracts/index.ts index ef1657abaf1..7683e468fd5 100644 --- a/turbo/packages/api-contracts/src/contracts/index.ts +++ b/turbo/packages/api-contracts/src/contracts/index.ts @@ -735,7 +735,6 @@ export { chatThreadMetadataSchema, chatThreadDraftSchema, MODEL_FIRST_SELECTION_PROVIDER_ID, - modelSelectionRequestSchema, generationTemplateRequestSchema, presentationGenerationTemplateRequestSchema, websiteGenerationTemplateRequestSchema, @@ -751,7 +750,6 @@ export { chatThreadArtifactGoogleDriveSyncSchema, chatThreadArtifactRunSchema, htmlArtifactEditSnapshotSchema, - type ModelSelectionRequest, type GenerationTemplateRequest, type PresentationGenerationTemplateRequest, type WebsiteGenerationTemplateRequest, diff --git a/turbo/packages/core/src/index.ts b/turbo/packages/core/src/index.ts index dbd4bb08300..b7faffc6b37 100644 --- a/turbo/packages/core/src/index.ts +++ b/turbo/packages/core/src/index.ts @@ -208,7 +208,6 @@ export { chatThreadListItemSchema, chatThreadDetailSchema, chatThreadDraftSchema, - modelSelectionRequestSchema, generationTemplateRequestSchema, pagedChatMessageSchema, summaryEntrySchema, @@ -533,7 +532,6 @@ export { type CheckpointResponse, type AgentComposeSnapshot, type VolumeVersionsSnapshot, - type ModelSelectionRequest, type GenerationTemplateRequest, type SummaryEntry, type ChatThreadsContract, From e786d5727decec6036b80778153e3639565c5f0c Mon Sep 17 00:00:00 2001 From: Ethan Zhang Date: Wed, 8 Jul 2026 14:40:24 +0800 Subject: [PATCH 03/10] fix: resolve chat model CI failures --- .../routes/__tests__/chat-callbacks.bdd.test.ts | 4 +++- .../signals/routes/__tests__/run-lifecycle.bdd.test.ts | 5 ++++- .../cli/src/commands/zero/chat/__tests__/model.test.ts | 7 +------ turbo/apps/cli/src/commands/zero/chat/model.ts | 10 ++-------- turbo/apps/cli/src/lib/api/domains/zero-chat.ts | 7 +++---- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts index 6be146720ad..7f73c0049f6 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-callbacks.bdd.test.ts @@ -167,7 +167,9 @@ async function startChatRun( ? {} : { attachFiles: body.attachFiles }), ...(body.selectedModel === undefined - ? { model: "claude-sonnet-4-6" } + ? body.threadId === undefined + ? { model: "claude-sonnet-4-6" } + : {} : { model: body.selectedModel }), }, [201], diff --git a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts index d4070c278b7..76a123bcc86 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts @@ -859,7 +859,10 @@ async function sendChatRunMessage( const chat = createChatFilesBddApi(context); const sent = await chat.requestSendMessage( actor, - { ...body, modelProvider: "anthropic-api-key" }, + { + ...body, + ...(body.threadId === undefined ? { model: "claude-sonnet-5" } : {}), + }, [201], ); if (sent.status !== 201 || sent.body.runId === null) { diff --git a/turbo/apps/cli/src/commands/zero/chat/__tests__/model.test.ts b/turbo/apps/cli/src/commands/zero/chat/__tests__/model.test.ts index 1fb1c15d3c0..37a746a7cec 100644 --- a/turbo/apps/cli/src/commands/zero/chat/__tests__/model.test.ts +++ b/turbo/apps/cli/src/commands/zero/chat/__tests__/model.test.ts @@ -15,8 +15,6 @@ import { server } from "../../../../mocks/server"; import { zeroChatCommand } from "../index"; const THREAD_ID = "00000000-0000-4000-8000-000000000001"; -const MODEL_FIRST_SELECTION_PROVIDER_ID = - "00000000-0000-4000-8000-000000000000"; const GET_URL = `http://localhost:3000/api/zero/chat-threads/${THREAD_ID}/metadata`; const MODEL_SELECTION_URL = `http://localhost:3000/api/zero/chat-threads/${THREAD_ID}/model-selection`; const MODEL_POLICIES_URL = "http://localhost:3000/api/zero/model-policies"; @@ -132,10 +130,7 @@ describe("zero chat model command", () => { "Bearer test-zero-token", ); await expect(request.json()).resolves.toStrictEqual({ - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "claude-sonnet-5", - }, + model: "claude-sonnet-5", }); return new HttpResponse(null, { status: 204 }); }), diff --git a/turbo/apps/cli/src/commands/zero/chat/model.ts b/turbo/apps/cli/src/commands/zero/chat/model.ts index dfcc7cb6624..098620fe14c 100644 --- a/turbo/apps/cli/src/commands/zero/chat/model.ts +++ b/turbo/apps/cli/src/commands/zero/chat/model.ts @@ -1,9 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; -import { - MODEL_FIRST_SELECTION_PROVIDER_ID, - type ChatThreadMetadata, -} from "@vm0/api-contracts/contracts/chat-threads"; +import type { ChatThreadMetadata } from "@vm0/api-contracts/contracts/chat-threads"; import type { OrgModelPolicy } from "@vm0/api-contracts/contracts/model-providers"; import { @@ -107,10 +104,7 @@ async function switchModel(threadId: string, model: string): Promise { const updated = await updateZeroChatThreadModelSelection({ threadId, - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: model, - }, + model, }); console.log(chalk.green("✓ Chat model updated")); diff --git a/turbo/apps/cli/src/lib/api/domains/zero-chat.ts b/turbo/apps/cli/src/lib/api/domains/zero-chat.ts index a82a1e08ebe..83bf6eddfdc 100644 --- a/turbo/apps/cli/src/lib/api/domains/zero-chat.ts +++ b/turbo/apps/cli/src/lib/api/domains/zero-chat.ts @@ -5,7 +5,6 @@ import { chatThreadRenameContract, chatSearchContract, type ChatThreadMetadata, - type ModelSelectionRequest, type ChatSearchResponse, } from "@vm0/api-contracts/contracts/chat-threads"; import { getClientConfig, handleError } from "../core/client-factory"; @@ -66,18 +65,18 @@ export async function getZeroChatThread(options: { export async function updateZeroChatThreadModelSelection(options: { threadId: string; - modelSelection: ModelSelectionRequest | null; + model: string | null; }): Promise<{ threadId: string; selectedModel: string | null }> { const config = await getClientConfig(); const client = initClient(chatThreadModelSelectionContract, config); const result = await client.update({ params: { id: options.threadId }, - body: { modelSelection: options.modelSelection }, + body: { model: options.model }, }); if (result.status === 204) { return { threadId: options.threadId, - selectedModel: options.modelSelection?.selectedModel ?? null, + selectedModel: options.model, }; } handleError(result, "Failed to update chat thread model"); From f9c552011662741bcd915ddf2f825a0ceebadc2a Mon Sep 17 00:00:00 2001 From: Ethan Zhang Date: Wed, 8 Jul 2026 15:12:09 +0800 Subject: [PATCH 04/10] fix: resolve CI failures on PR #20639 --- .../__tests__/chat-messages.bdd.test.ts | 24 +-- .../contracts/__tests__/chat-threads.test.ts | 75 +++++++++ .../src/contracts/chat-threads.ts | 156 ++++++++++++------ 3 files changed, 183 insertions(+), 72 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts index b2071493f1c..2636b36671c 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts @@ -1657,16 +1657,13 @@ describe("CHAT-02: admission without spendable credits", () => { const agent = await bdd.createAgent(actor, { displayName: "Pro-suspend chat agent", }); - const { providerId } = await upsertOrgModelProvider(actor, { - type: "vm0", - }); await api.updateOrgModelPolicies(actor, [ { model: "claude-sonnet-4-6", isDefault: true, defaultProviderType: "vm0", credentialScope: "org", - modelProviderId: providerId, + modelProviderId: null, }, ]); @@ -1802,16 +1799,13 @@ describe("CHAT-02: model-first provider policies", () => { // database: 503 when no vm0 execution key exists (no public provisioning // surface), 201 when another suite's alive legacy test has seeded a // global vm0 key. Both prove the credits-ok admission arm. - const { providerId: vm0Id } = await upsertOrgModelProvider(actor, { - type: "vm0", - }); await api.updateOrgModelPolicies(actor, [ { model: "claude-sonnet-4-6", isDefault: true, defaultProviderType: "vm0", credentialScope: "org", - modelProviderId: vm0Id, + modelProviderId: null, }, ]); const vm0Send = await requestSendMessageRaw(actor, { @@ -2112,16 +2106,13 @@ describe("CHAT-02: model-first provider policies", () => { }; await (async () => { - const { providerId } = await upsertOrgModelProvider(actor, { - type: "vm0", - }); await api.updateOrgModelPolicies(actor, [ { model: "kimi-k2.7-code", isDefault: true, defaultProviderType: "vm0", credentialScope: "org", - modelProviderId: providerId, + modelProviderId: null, }, ]); @@ -2170,16 +2161,13 @@ describe("CHAT-02: model-first provider policies", () => { ], }); - const { providerId } = await upsertOrgModelProvider(actor, { - type: "vm0", - }); await api.updateOrgModelPolicies(actor, [ { model: "glm-5.2", isDefault: true, defaultProviderType: "vm0", credentialScope: "org", - modelProviderId: providerId, + modelProviderId: null, }, ]); @@ -2453,7 +2441,9 @@ describe("CHAT-02: run-level model overrides", () => { [400], ); expectApiError(invalidVm0Model.body); - expect(invalidVm0Model.body.error.message).toBe("Invalid model selection"); + expect(invalidVm0Model.body.error.message).toBe( + "model: Invalid model selection", + ); await chat.requestReadThread(actor, vm0ThreadId, [404]); // Removed sentinel models fail contract validation. diff --git a/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts index 5fab7d155b2..d7bc0479221 100644 --- a/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts +++ b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts @@ -5,9 +5,84 @@ import { artifactsContract, artifactsListQuerySchema, artifactsListResponseSchema, + chatMessagesContract, + chatThreadModelSelectionContract, + chatThreadsContract, generationTemplateRequestSchema, + MODEL_FIRST_SELECTION_PROVIDER_ID, } from "../chat-threads"; +const legacyModelSelection = { + modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, + selectedModel: "claude-sonnet-4-6", +}; + +describe("chat thread model request compatibility", () => { + it("normalizes legacy thread create modelSelection bodies to model", () => { + const parsed = chatThreadsContract.create.body.safeParse({ + agentId: "agent-1", + modelSelection: legacyModelSelection, + title: "Launch plan", + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toMatchObject({ + agentId: "agent-1", + model: "claude-sonnet-4-6", + title: "Launch plan", + }); + expect(parsed.data).not.toHaveProperty("modelSelection"); + }); + + it("normalizes legacy thread model update bodies to model", () => { + const parsed = chatThreadModelSelectionContract.update.body.safeParse({ + modelSelection: legacyModelSelection, + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toStrictEqual({ model: "claude-sonnet-4-6" }); + }); + + it("normalizes legacy thread model clears to model null", () => { + const parsed = chatThreadModelSelectionContract.update.body.safeParse({ + modelSelection: null, + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toStrictEqual({ model: null }); + }); + + it("normalizes legacy chat send modelSelection bodies to model", () => { + const parsed = chatMessagesContract.send.body.safeParse({ + agentId: "agent-1", + prompt: "Build a launch plan", + modelProvider: "anthropic-api-key", + modelSelection: legacyModelSelection, + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toMatchObject({ + agentId: "agent-1", + prompt: "Build a launch plan", + model: "claude-sonnet-4-6", + }); + expect(parsed.data).not.toHaveProperty("modelProvider"); + expect(parsed.data).not.toHaveProperty("modelSelection"); + }); +}); + describe("chat thread generation template contract", () => { it("accepts presentation template selections", () => { const parsed = generationTemplateRequestSchema.safeParse({ diff --git a/turbo/packages/api-contracts/src/contracts/chat-threads.ts b/turbo/packages/api-contracts/src/contracts/chat-threads.ts index 6f9376abe80..a6a065d5234 100644 --- a/turbo/packages/api-contracts/src/contracts/chat-threads.ts +++ b/turbo/packages/api-contracts/src/contracts/chat-threads.ts @@ -392,31 +392,112 @@ const selectedModelRequestSchema = z } }); -const chatThreadCreateBodySchema = z.object({ - agentId: z.string().min(1), - clientThreadId: z.string().uuid().optional(), - eventId: chatThreadEventIdSchema.optional(), - /** - * Selected model id. The API resolves the effective model provider from org - * policy and available credentials. - */ - model: selectedModelRequestSchema, - title: z.string().optional(), -}); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} -const chatThreadModelSelectionUpdateBodySchema = z.object({ - /** - * Selected model id, or null to clear the thread's selected model. - */ - model: selectedModelRequestSchema.nullable(), - codexServiceTier: codexServiceTierSchema.nullable().optional(), - eventId: chatThreadEventIdSchema.optional(), -}); +function legacyModelSelectionModel(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + if (!isRecord(value)) { + return undefined; + } + const selectedModel = value.selectedModel; + return typeof selectedModel === "string" ? selectedModel : undefined; +} + +function normalizeLegacyModelSelectionInput( + value: unknown, + options: { readonly allowNull: boolean }, +): unknown { + if (!isRecord(value) || "model" in value || !("modelSelection" in value)) { + return value; + } + const model = legacyModelSelectionModel(value.modelSelection); + if (model === undefined || (model === null && !options.allowNull)) { + return value; + } + return { ...value, model }; +} + +const chatThreadCreateBodySchema = z.preprocess( + (value) => { + return normalizeLegacyModelSelectionInput(value, { allowNull: false }); + }, + z.object({ + agentId: z.string().min(1), + clientThreadId: z.string().uuid().optional(), + eventId: chatThreadEventIdSchema.optional(), + /** + * Selected model id. The API resolves the effective model provider from org + * policy and available credentials. + */ + model: selectedModelRequestSchema, + title: z.string().optional(), + }), +); + +const chatThreadModelSelectionUpdateBodySchema = z.preprocess( + (value) => { + return normalizeLegacyModelSelectionInput(value, { allowNull: true }); + }, + z.object({ + /** + * Selected model id, or null to clear the thread's selected model. + */ + model: selectedModelRequestSchema.nullable(), + codexServiceTier: codexServiceTierSchema.nullable().optional(), + eventId: chatThreadEventIdSchema.optional(), + }), +); const chatRunOptionsRequestSchema = z.object({ codexServiceTier: codexServiceTierSchema.optional(), }); +const chatMessageNormalSendBodySchema = z.preprocess( + (value) => { + return normalizeLegacyModelSelectionInput(value, { allowNull: false }); + }, + z.object({ + agentId: z.string().min(1), + prompt: z.string().min(1), + threadId: z.string().optional(), + clientThreadId: z.string().uuid().optional(), + chatThreadEventId: chatThreadEventIdSchema.optional(), + // Client-generated UUID for the sort touch created by direct user sends. + // Lets event-sourced clients reconcile optimistic sidebar recency by id. + chatThreadSortEventId: chatThreadEventIdSchema.optional(), + /** + * Selected model id. The API resolves the effective provider from org + * policy and available credentials. Existing threads may omit it to + * reuse the thread's persisted model. + */ + model: selectedModelRequestSchema.optional(), + runOptions: chatRunOptionsRequestSchema.optional(), + generationTemplate: generationTemplateRequestSchema.optional(), + computerUseHostId: z.string().uuid().nullable().optional(), + // Optional for backward compatibility: older clients that omit this field + // still trigger title generation (server guards with !== false, not === true). + hasTextContent: z.boolean().optional(), + attachFiles: z.array(attachFileSchema).optional(), + // Client-generated UUID used as the user message's primary key. + // Lets the client render an optimistic row and reconcile with the + // server row by id — no temp-id swap, no React remount. + clientMessageId: z.string().uuid().optional(), + // Test-only escape hatch: when the host runner has USE_MOCK_CODEX + // set (CI default), allow the request to bypass the mock and execute + // the real codex CLI. Mirrors `debugNoMockClaude` / `debugNoMockCodex` + // on /api/zero/runs so e2e BYOK smoke tests can exercise the chat + // entry path end-to-end. + debugNoMockClaude: z.boolean().optional(), + debugNoMockCodex: z.boolean().optional(), + revokesMessageId: z.string().min(1).optional(), + interruptsRunId: z.undefined().optional(), + }), +); + /** * Chat thread collection route contract. */ @@ -819,42 +900,7 @@ export const chatMessagesContract = c.router({ path: "/api/zero/chat/messages", headers: authHeadersSchema, body: z.union([ - z.object({ - agentId: z.string().min(1), - prompt: z.string().min(1), - threadId: z.string().optional(), - clientThreadId: z.string().uuid().optional(), - chatThreadEventId: chatThreadEventIdSchema.optional(), - // Client-generated UUID for the sort touch created by direct user sends. - // Lets event-sourced clients reconcile optimistic sidebar recency by id. - chatThreadSortEventId: chatThreadEventIdSchema.optional(), - /** - * Selected model id. The API resolves the effective provider from org - * policy and available credentials. Existing threads may omit it to - * reuse the thread's persisted model. - */ - model: selectedModelRequestSchema.optional(), - runOptions: chatRunOptionsRequestSchema.optional(), - generationTemplate: generationTemplateRequestSchema.optional(), - computerUseHostId: z.string().uuid().nullable().optional(), - // Optional for backward compatibility: older clients that omit this field - // still trigger title generation (server guards with !== false, not === true). - hasTextContent: z.boolean().optional(), - attachFiles: z.array(attachFileSchema).optional(), - // Client-generated UUID used as the user message's primary key. - // Lets the client render an optimistic row and reconcile with the - // server row by id — no temp-id swap, no React remount. - clientMessageId: z.string().uuid().optional(), - // Test-only escape hatch: when the host runner has USE_MOCK_CODEX - // set (CI default), allow the request to bypass the mock and execute - // the real codex CLI. Mirrors `debugNoMockClaude` / `debugNoMockCodex` - // on /api/zero/runs so e2e BYOK smoke tests can exercise the chat - // entry path end-to-end. - debugNoMockClaude: z.boolean().optional(), - debugNoMockCodex: z.boolean().optional(), - revokesMessageId: z.string().min(1).optional(), - interruptsRunId: z.undefined().optional(), - }), + chatMessageNormalSendBodySchema, z.object({ agentId: z.string().min(1), threadId: z.string().min(1), From 7fb8b8f7e04859c1b84733e6edeea17a8e16a960 Mon Sep 17 00:00:00 2001 From: Ethan Zhang Date: Wed, 8 Jul 2026 15:35:19 +0800 Subject: [PATCH 05/10] fix: resolve CI failures on PR #20639 --- .../src/signals/routes/__tests__/run-lifecycle.bdd.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts index d8a2fe48583..2c474e8bcdb 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts @@ -3065,11 +3065,7 @@ describe("RUN-02: model provider selection and vm0 admission", () => { { agentId, prompt: "vm0 managed minimax codex provider", - modelProvider: "vm0", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel, - }, + model: selectedModel, }, [201], ); From 4f9dcd9241f3cb6aaf938658faddc05010efdc06 Mon Sep 17 00:00:00 2001 From: Ethan Zhang Date: Wed, 8 Jul 2026 16:09:05 +0800 Subject: [PATCH 06/10] fix: resolve CI failures on PR #20639 --- .../routes/__tests__/run-lifecycle.bdd.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts index 2c474e8bcdb..52665f84712 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts @@ -3059,6 +3059,22 @@ describe("RUN-02: model provider selection and vm0 admission", () => { [FeatureSwitchKey.CodexFrameworkForMinimax]: true, }, ); + await api.updateOrgModelPolicies(actor, [ + { + model: "claude-sonnet-4-6", + isDefault: true, + defaultProviderType: "vm0", + credentialScope: "org", + modelProviderId: null, + }, + { + model: "MiniMax-M3", + isDefault: false, + defaultProviderType: "vm0", + credentialScope: "org", + modelProviderId: null, + }, + ]); const sent = await chat.requestSendMessage( actor, From f21361726ea000d77f0f12c44d141238aab4fa5b Mon Sep 17 00:00:00 2001 From: Ethan Zhang Date: Wed, 8 Jul 2026 19:07:48 +0800 Subject: [PATCH 07/10] fix: resolve CI failures on PR #20639 --- .../src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts b/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts index 52f2fd90c39..d5afd0751de 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/helpers/api-bdd-chat-files.ts @@ -18,7 +18,6 @@ import { chatThreadMessagesContract, type AttachFile, type ArtifactsListResponse, - type ArtifactsListQuery, type ChatSearchResponse, type ChatThreadArtifactRun, type ChatThreadDetail, @@ -97,8 +96,6 @@ import { createZeroRouteMocks } from "./zero-route-test"; export { hostedTextFile } from "./api-bdd-host-files"; export { storageTextFile } from "./api-bdd-storage-files"; -type ArtifactsListRequestQuery = Partial; - function defaultCreateThreadModel(): string { return "claude-sonnet-4-6"; } From 4345cfaf9431d6260e1dce5953a50eaea7316e09 Mon Sep 17 00:00:00 2001 From: Lancy Date: Thu, 9 Jul 2026 13:03:31 +0800 Subject: [PATCH 08/10] fix: align chat model e2e with model-only writes --- e2e/helpers/codex-zero.bash | 50 +++++++++++++++--- e2e/helpers/setup.bash | 26 ++++++++++ .../03-runner/t-codex-zero-byok-smoke.bats | 22 ++++---- .../t30-vm0-model-provider-inject.bats | 45 ++++++++-------- .../03-runner/t54-vm0-billable-firewall.bats | 51 ++++++++++++------- .../contracts/__tests__/chat-threads.test.ts | 34 +++++++++++++ .../src/contracts/chat-threads.ts | 29 ++++++++--- 7 files changed, 194 insertions(+), 63 deletions(-) diff --git a/e2e/helpers/codex-zero.bash b/e2e/helpers/codex-zero.bash index d942c69bd34..6c1e8d10517 100644 --- a/e2e/helpers/codex-zero.bash +++ b/e2e/helpers/codex-zero.bash @@ -85,6 +85,49 @@ disable_codex_beta() { return 0 } +configure_codex_zero_model_policy() { + local model="$1" + local provider_type="$2" + local provider_id="$3" + local current payload + + current=$(_codex_zero_curl "/api/zero/model-policies") + payload=$(printf '%s' "$current" | jq -c \ + --arg model "$model" \ + --arg providerType "$provider_type" \ + --arg providerId "$provider_id" \ + ' + (.policies // []) + | map({ + model, + isDefault, + defaultProviderType, + credentialScope, + modelProviderId + }) as $existing + | ($existing | map(select(.model != $model))) as $others + | ( + ($existing | map(select(.model == $model)) | first) + // {model: $model, isDefault: false} + ) as $target + | { + policies: ( + $others + + [ + $target + + { + defaultProviderType: $providerType, + credentialScope: "org", + modelProviderId: $providerId + } + ] + ) + } + ') + + _codex_zero_curl "/api/zero/model-policies" -X PUT -d "$payload" >/dev/null +} + # Poll /api/zero/chat-threads/:id/messages until the current run has a terminal # assistant lifecycle marker (the paged message API no longer exposes agent run # status; terminal runs append a null-content lifecycle marker row instead). @@ -191,17 +234,12 @@ send_chat_run_message() { # guest_mock_codex::build_events. The chat/messages contract # exposes this flag via chatMessagesContract.body.debugNoMockCodex, mirroring # the same passthrough on /api/zero/runs. - # Model-first selection can carry either the sentinel provider id for an org - # policy route, or a concrete provider id for an explicit model/provider pin. - # BYOK smoke tests use a concrete id so they do not mutate shared org policy. local selected_model="${CODEX_ZERO_SELECTED_MODEL:-gpt-5.5}" - local model_provider_id="${CODEX_ZERO_MODEL_PROVIDER_ID:-00000000-0000-4000-8000-000000000000}" payload=$(jq -nc \ --arg agentId "$agent_id" \ --arg prompt "$prompt" \ - --arg modelProviderId "$model_provider_id" \ --arg selectedModel "$selected_model" \ - '{agentId: $agentId, prompt: $prompt, modelSelection: {modelProviderId: $modelProviderId, selectedModel: $selectedModel}, hasTextContent: true, debugNoMockCodex: true}') + '{agentId: $agentId, prompt: $prompt, model: $selectedModel, hasTextContent: true, debugNoMockCodex: true}') body=$(_codex_zero_curl "/api/zero/chat/messages" \ -X POST \ -d "$payload") diff --git a/e2e/helpers/setup.bash b/e2e/helpers/setup.bash index b3dd4b160bc..6cc068e10cd 100644 --- a/e2e/helpers/setup.bash +++ b/e2e/helpers/setup.bash @@ -235,6 +235,32 @@ zero_model_first_selection_provider_id() { printf '%s' "00000000-0000-4000-8000-000000000000" } +zero_chat_run_with_model() { + local agent_id="$1" + local prompt="$2" + local selected_model="$3" + local debug_no_mock_claude="${4:-false}" + local debug_no_mock_codex="${5:-false}" + local payload body + + payload=$(jq -nc \ + --arg agentId "$agent_id" \ + --arg prompt "$prompt" \ + --arg selectedModel "$selected_model" \ + --argjson debugNoMockClaude "$debug_no_mock_claude" \ + --argjson debugNoMockCodex "$debug_no_mock_codex" \ + '{agentId: $agentId, prompt: $prompt, model: $selectedModel, hasTextContent: true, debugNoMockClaude: $debugNoMockClaude, debugNoMockCodex: $debugNoMockCodex}') + + body=$(zero_curl "/api/zero/chat/messages" -X POST -d "$payload") + LAST_RUN_ID=$(printf '%s' "$body" | jq -r '.runId // ""') + LAST_THREAD_ID=$(printf '%s' "$body" | jq -r '.threadId // ""') + export LAST_RUN_ID LAST_THREAD_ID + [[ -n "$LAST_RUN_ID" && -n "$LAST_THREAD_ID" ]] || { + echo "# zero_chat_run_with_model: bad response: $body" >&2 + return 1 + } +} + zero_chat_run_with_model_selection() { local agent_id="$1" local prompt="$2" diff --git a/e2e/tests/03-runner/t-codex-zero-byok-smoke.bats b/e2e/tests/03-runner/t-codex-zero-byok-smoke.bats index 228701d5e39..023e54019c7 100644 --- a/e2e/tests/03-runner/t-codex-zero-byok-smoke.bats +++ b/e2e/tests/03-runner/t-codex-zero-byok-smoke.bats @@ -31,18 +31,20 @@ setup_file() { # 1. Feature switch on (also fails the file early if not yet wired) enable_codex_beta - # 2. Org-level openai-api-key provider. The selected model is explicitly - # pinned to this provider on the chat message, without provider defaults or - # shared org policy mutation. + # 2. Org-level openai-api-key provider. Chat writes only carry the selected + # model; provider resolution comes from org model policy. $ZERO_CLI org model-provider setup --type "openai-api-key" --secret "$OPENAI_API_KEY" >/dev/null export OPENAI_PROVIDER_ID OPENAI_PROVIDER_ID=$(zero_model_provider_id_by_type "openai-api-key") export CODEX_ZERO_SELECTED_MODEL="gpt-5.4-mini" - export CODEX_ZERO_MODEL_PROVIDER_ID="$OPENAI_PROVIDER_ID" + configure_codex_zero_model_policy \ + "$CODEX_ZERO_SELECTED_MODEL" \ + "openai-api-key" \ + "$OPENAI_PROVIDER_ID" # 3. Compose declares framework: codex explicitly. The framework is - # resolved from the explicit model-first provider pin. At secret resolution - # the provider's declared framework wins (Epic #11520) and is propagated + # resolved from the org model policy route. At secret resolution the + # provider's declared framework wins (Epic #11520) and is propagated # downstream via build-zero-context.ts's resolvedFramework, with no # compose-vs-provider equality check. cat > "$TEST_DIR/vm0-basic.yaml" < "$TEST_DIR/vm0.yaml" </dev/null } teardown() { - [ -n "$THREAD_ID" ] && zero_curl "/api/zero/chat-threads/$THREAD_ID" -X DELETE >/dev/null 2>&1 || true - [ -n "$AGENT_ID" ] && $ZERO_CLI agent delete "$AGENT_ID" -y 2>/dev/null || true + if [ -n "$TEST_DIR" ] && [ -d "$TEST_DIR" ]; then + rm -rf "$TEST_DIR" + fi } @test "model-provider credential is injected into container" { - local provider_id - provider_id=$(zero_model_provider_id_by_type "claude-code-oauth-token") - - AGENT_ID=$(create_private_zero_agent "e2e-mp-inject-${UNIQUE_ID}") || { - echo "# Failed to create private zero agent" >&2 - return 1 - } - - zero_chat_run_with_model_selection \ - "$AGENT_ID" \ - "case \"\$CLAUDE_CODE_OAUTH_TOKEN\" in \"\"|\"***\") marker=MISMATCH ;; *) marker=OK ;; esac; printf 'INJECTED_%s\n' \"\$marker\"" \ - "$provider_id" \ - "claude-sonnet-4-6" \ - false \ - false - THREAD_ID="$LAST_THREAD_ID" + zero_model_provider_id_by_type "claude-code-oauth-token" >/dev/null # GitHub Actions masks sk-ant-like values in logs, so emit a non-secret marker # only after verifying the injected token is present inside the container. - WAIT_FOR_LOG_TIMEOUT=60 wait_for_log "$LAST_RUN_ID" -- "INJECTED_OK" + run $VM0_CLI run "$AGENT_NAME" \ + --model-provider-type "claude-code-oauth-token" \ + "case \"\$CLAUDE_CODE_OAUTH_TOKEN\" in \"\"|\"***\") marker=MISMATCH ;; *) marker=OK ;; esac; printf 'INJECTED_%s\n' \"\$marker\"" + + echo "$output" + assert_success assert_output --partial "INJECTED_OK" } diff --git a/e2e/tests/03-runner/t54-vm0-billable-firewall.bats b/e2e/tests/03-runner/t54-vm0-billable-firewall.bats index d1e957057f2..916f8088fc0 100644 --- a/e2e/tests/03-runner/t54-vm0-billable-firewall.bats +++ b/e2e/tests/03-runner/t54-vm0-billable-firewall.bats @@ -2,11 +2,12 @@ # Verify firewall_billable propagation through the full stack. # -# Uses explicit model-first pins for provider selection. The test never changes -# the shared e2e org's workspace default, so no race with other chunks. +# BYOK selection uses the runner provider-type path so chat writes do not carry +# provider pins. The vm0 case exercises the web chat model-only path and relies +# on org model policy for provider resolution. # -# t54-0: run pinned to org BYOK anthropic-api-key; "$" marker absent. -# t54-1: run uses the model policy's vm0 route; billableFirewalls covers +# t54-0: direct run with org BYOK anthropic-api-key; "$" marker absent. +# t54-1: chat run uses the model policy's vm0 route; billableFirewalls covers # the concrete anthropic firewall → "$" marker present. load '../../helpers/setup' @@ -17,13 +18,26 @@ setup_file() { fi export UNIQUE_ID="$(date +%s%3N)-$RANDOM" + export TEST_DIR="$(mktemp -d)" + export RUN_AGENT_NAME="e2e-billable-runner-${UNIQUE_ID}" export THREAD_IDS="" $ZERO_CLI org model-provider setup \ --type anthropic-api-key \ --secret "$ANTHROPIC_API_KEY" >/dev/null - export ANTHROPIC_PROVIDER_ID - ANTHROPIC_PROVIDER_ID=$(zero_model_provider_id_by_type "anthropic-api-key") + zero_model_provider_id_by_type "anthropic-api-key" >/dev/null + + cat > "$TEST_DIR/vm0.yaml" </dev/null # Create a private zero agent for this file so CI does not consume the # shared org's limited public-agent slots. @@ -46,26 +60,26 @@ teardown_file() { zero_curl "/api/zero/chat-threads/$thread_id" -X DELETE >/dev/null 2>&1 || true done [ -n "$AGENT_ID" ] && $ZERO_CLI agent delete "$AGENT_ID" -y 2>/dev/null || true + if [ -n "$TEST_DIR" ] && [ -d "$TEST_DIR" ]; then + rm -rf "$TEST_DIR" + fi } @test "t54-0: BYOK provider — firewall not billable" { - zero_chat_run_with_model_selection \ - "$AGENT_ID" \ - "Reply with exactly: DONE" \ - "$ANTHROPIC_PROVIDER_ID" \ - "claude-sonnet-4-6" \ - true \ - false - THREAD_IDS="$THREAD_IDS $LAST_THREAD_ID" - export THREAD_IDS + run $VM0_CLI run "$RUN_AGENT_NAME" \ + --model-provider-type "anthropic-api-key" \ + --debug-no-mock-claude \ + "Reply with exactly: DONE" - RUN_ID="$LAST_RUN_ID" + echo "$output" + assert_success + + RUN_ID=$(echo "$output" | grep -oP 'Run ID:\s+\K[a-f0-9-]{36}' | head -1) [ -n "$RUN_ID" ] || { echo "# Failed to extract Run ID" return 1 } - wait_for_zero_run_completed "$RUN_ID" WAIT_FOR_LOG_TIMEOUT=60 wait_for_log "$RUN_ID" --network -- "[model-provider:anthropic-api-key]" refute_output --partial '[model-provider:anthropic-api-key $]' @@ -74,10 +88,9 @@ teardown_file() { } @test "t54-1: vm0 meta-provider — firewall billable" { - zero_chat_run_with_model_selection \ + zero_chat_run_with_model \ "$AGENT_ID" \ "Reply with exactly: DONE" \ - "$(zero_model_first_selection_provider_id)" \ "claude-sonnet-4-6" \ true \ false diff --git a/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts index 8f1c5cc1174..99b22bf0b0d 100644 --- a/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts +++ b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts @@ -16,6 +16,11 @@ const legacyModelSelection = { selectedModel: "claude-sonnet-4-6", }; +const legacyProviderPinnedModelSelection = { + modelProviderId: "11111111-1111-4111-8111-111111111111", + selectedModel: "claude-sonnet-4-6", +}; + describe("chat thread model request compatibility", () => { it("normalizes legacy thread create modelSelection bodies to model", () => { const parsed = chatThreadsContract.create.body.safeParse({ @@ -80,6 +85,35 @@ describe("chat thread model request compatibility", () => { expect(parsed.data).not.toHaveProperty("modelProvider"); expect(parsed.data).not.toHaveProperty("modelSelection"); }); + + it("rejects legacy thread create bodies pinned to a concrete provider", () => { + const parsed = chatThreadsContract.create.body.safeParse({ + agentId: "agent-1", + modelSelection: legacyProviderPinnedModelSelection, + title: "Launch plan", + }); + + expect(parsed.success).toBe(false); + }); + + it("rejects legacy thread model updates pinned to a concrete provider", () => { + const parsed = chatThreadModelSelectionContract.update.body.safeParse({ + modelSelection: legacyProviderPinnedModelSelection, + }); + + expect(parsed.success).toBe(false); + }); + + it("rejects legacy chat send bodies pinned to a concrete provider", () => { + const parsed = chatMessagesContract.send.body.safeParse({ + agentId: "agent-1", + prompt: "Build a launch plan", + modelProvider: "anthropic-api-key", + modelSelection: legacyProviderPinnedModelSelection, + }); + + expect(parsed.success).toBe(false); + }); }); describe("chat thread generation template contract", () => { diff --git a/turbo/packages/api-contracts/src/contracts/chat-threads.ts b/turbo/packages/api-contracts/src/contracts/chat-threads.ts index 1dc65087040..0d7b115d5bb 100644 --- a/turbo/packages/api-contracts/src/contracts/chat-threads.ts +++ b/turbo/packages/api-contracts/src/contracts/chat-threads.ts @@ -388,15 +388,26 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function legacyModelSelectionModel(value: unknown): string | null | undefined { +type LegacyModelSelectionResult = + | { readonly kind: "model"; readonly model: string | null } + | { readonly kind: "reject" }; + +function legacyModelSelectionModel( + value: unknown, +): LegacyModelSelectionResult | undefined { if (value === null) { - return null; + return { kind: "model", model: null }; } if (!isRecord(value)) { return undefined; } + if (value.modelProviderId !== MODEL_FIRST_SELECTION_PROVIDER_ID) { + return { kind: "reject" }; + } const selectedModel = value.selectedModel; - return typeof selectedModel === "string" ? selectedModel : undefined; + return typeof selectedModel === "string" + ? { kind: "model", model: selectedModel } + : undefined; } function normalizeLegacyModelSelectionInput( @@ -406,11 +417,17 @@ function normalizeLegacyModelSelectionInput( if (!isRecord(value) || "model" in value || !("modelSelection" in value)) { return value; } - const model = legacyModelSelectionModel(value.modelSelection); - if (model === undefined || (model === null && !options.allowNull)) { + const legacyModelSelection = legacyModelSelectionModel(value.modelSelection); + if (legacyModelSelection?.kind === "reject") { + return { ...value, model: value.modelSelection }; + } + if ( + legacyModelSelection === undefined || + (legacyModelSelection.model === null && !options.allowNull) + ) { return value; } - return { ...value, model }; + return { ...value, model: legacyModelSelection.model }; } const chatThreadCreateBodySchema = z.preprocess( From fb321c66ac91a9633369983795594493950b4f42 Mon Sep 17 00:00:00 2001 From: Lancy Date: Thu, 9 Jul 2026 17:07:13 +0800 Subject: [PATCH 09/10] fix: resolve ci failures on pr 20639 --- .../__tests__/chat-messages.bdd.test.ts | 11 +- .../helpers/zero-model-provider-state.ts | 65 +++++++++++ .../routes/__tests__/zero-goals.test.ts | 2 +- .../__tests__/zero-usage-insight.test.ts | 2 +- .../routes/test-model-provider-state.ts | 104 ++++++++++++++++++ .../chat-page/optimistic-chat-thread-page.ts | 4 +- .../contracts/__tests__/chat-threads.test.ts | 35 +++++- .../src/contracts/chat-threads.ts | 24 +--- .../api-contracts/src/contracts/index.ts | 9 ++ .../contracts/test-model-provider-state.ts | 48 ++++++++ 10 files changed, 269 insertions(+), 35 deletions(-) create mode 100644 turbo/apps/api/src/signals/routes/__tests__/helpers/zero-model-provider-state.ts create mode 100644 turbo/apps/api/src/signals/routes/test-model-provider-state.ts create mode 100644 turbo/packages/api-contracts/src/contracts/test-model-provider-state.ts diff --git a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts index 2b0866b8a58..ea208c248f7 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/chat-messages.bdd.test.ts @@ -43,6 +43,7 @@ import { createRunsAutomationsApi } from "./helpers/api-bdd-runs-automations"; import { createWebhookCallbackApi } from "./helpers/api-bdd-webhooks"; import { createZeroRouteMocks } from "./helpers/zero-route-test"; import { updateFeatureSwitchesForUser } from "./helpers/zero-feature-switches"; +import { overwriteModelProviderSecretForTests } from "./helpers/zero-model-provider-state"; import { deleteBddVm0ApiKeys, replaceBddVm0ApiKeys, @@ -2006,10 +2007,6 @@ describe("CHAT-02: model-first provider policies", () => { it("rejects legacy blank OpenRouter provider secrets during firewall auth", async () => { const fw = createFirewallApi(context); const { actor, agentId, runnerGroup } = await entitledChatActor(); - const orgId = actor.orgId; - if (!orgId) { - throw new Error("Expected entitled actor to have an org"); - } const { providerId } = await upsertOrgModelProvider(actor, { type: "openrouter-api-key", secret: "test-openrouter-key", @@ -2023,7 +2020,11 @@ describe("CHAT-02: model-first provider policies", () => { modelProviderId: providerId, }, ]); - await overwriteOrgModelProviderSecret(orgId, "OPENROUTER_API_KEY", " "); + await overwriteModelProviderSecretForTests(context.signal, { + providerId, + secretName: "OPENROUTER_API_KEY", + secret: " ", + }); const run = await sendChatRun(actor, { agentId, diff --git a/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-model-provider-state.ts b/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-model-provider-state.ts new file mode 100644 index 00000000000..3eae357bb6c --- /dev/null +++ b/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-model-provider-state.ts @@ -0,0 +1,65 @@ +import type { + TestModelProviderStateActionBody, + TestModelProviderStateActionResponse, +} from "@vm0/api-contracts/contracts/test-model-provider-state"; + +import { createAppWithRoutes } from "../../../../app-factory-core"; +import { testModelProviderStateRoutes } from "../../test-model-provider-state"; + +const MODEL_PROVIDER_STATE_ROUTE = "/api/test/model-provider-state"; + +function requestModelProviderState( + signal: AbortSignal, + path: string, + init?: RequestInit, +): Promise { + const app = createAppWithRoutes({ + signal, + routes: testModelProviderStateRoutes, + }); + return Promise.resolve(app.request(path, init)); +} + +async function readJson(response: Response): Promise { + return (await response.json()) as T; +} + +function expectOk(response: Response, operation: string): void { + if (response.ok) { + return; + } + throw new Error(`${operation} failed with ${response.status}`); +} + +async function postAction( + signal: AbortSignal, + body: TestModelProviderStateActionBody, +): Promise { + const response = await requestModelProviderState( + signal, + `${MODEL_PROVIDER_STATE_ROUTE}/action`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + expectOk(response, `model provider state action ${body.action}`); + return await readJson(response); +} + +export async function overwriteModelProviderSecretForTests( + signal: AbortSignal, + args: { + readonly providerId: string; + readonly secretName: string; + readonly secret: string; + }, +): Promise { + await postAction(signal, { + action: "overwrite-secret", + provider_id: args.providerId, + secret_name: args.secretName, + secret: args.secret, + }); +} diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-goals.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-goals.test.ts index a5ba3070cb6..bc6c6acf6c6 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-goals.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-goals.test.ts @@ -82,7 +82,7 @@ async function seedGoalApiFixture(): Promise { { agentId: agent.agentId, prompt: "goal precondition", - modelProvider: "anthropic-api-key", + model: "claude-sonnet-4-6", }, [201], ); diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-usage-insight.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-usage-insight.test.ts index 289ffad2819..91074337810 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-usage-insight.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-usage-insight.test.ts @@ -588,7 +588,7 @@ describe("GET /api/zero/usage/insight", () => { agentId: agent.agentId, prompt: "generate chat usage", threadId: thread.id, - modelProvider: "anthropic-api-key", + model: "claude-sonnet-4-6", }, [201], ); diff --git a/turbo/apps/api/src/signals/routes/test-model-provider-state.ts b/turbo/apps/api/src/signals/routes/test-model-provider-state.ts new file mode 100644 index 00000000000..1f07029834a --- /dev/null +++ b/turbo/apps/api/src/signals/routes/test-model-provider-state.ts @@ -0,0 +1,104 @@ +import { command } from "ccstate"; +import { + testModelProviderStateContract, + type TestModelProviderStateActionBody, +} from "@vm0/api-contracts/contracts/test-model-provider-state"; +import { modelProviders } from "@vm0/db/schema/model-provider"; +import { secrets } from "@vm0/db/schema/secret"; +import { and, eq } from "drizzle-orm"; + +import { nowDate } from "../../lib/time"; +import { request$ } from "../context/hono"; +import { bodyResultOf } from "../context/request"; +import { writeDb$, type Db } from "../external/db"; +import type { RouteEntry } from "../route-entry"; +import { encryptStoredSecretValue } from "../services/crypto.utils"; +import { + isTestEndpointAllowed, + testEndpointNotFoundResponse, +} from "./test-oauth-provider-helpers"; + +const actionBody$ = bodyResultOf(testModelProviderStateContract.action); + +async function overwriteModelProviderSecret( + db: Db, + body: Extract< + TestModelProviderStateActionBody, + { readonly action: "overwrite-secret" } + >, + signal: AbortSignal, +) { + const [provider] = await db + .select({ secretId: modelProviders.secretId }) + .from(modelProviders) + .where(eq(modelProviders.id, body.provider_id)) + .limit(1); + signal.throwIfAborted(); + if (!provider?.secretId) { + return { + status: 400 as const, + body: { error: "Model provider secret not found" }, + }; + } + + const encryptedValue = await encryptStoredSecretValue(body.secret); + signal.throwIfAborted(); + const [updated] = await db + .update(secrets) + .set({ encryptedValue, updatedAt: nowDate() }) + .where( + and( + eq(secrets.id, provider.secretId), + eq(secrets.name, body.secret_name), + ), + ) + .returning({ id: secrets.id }); + signal.throwIfAborted(); + if (!updated) { + return { + status: 400 as const, + body: { error: "Model provider secret not found" }, + }; + } + + return { status: 200 as const, body: { ok: true as const } }; +} + +async function mutateModelProviderState( + db: Db, + body: TestModelProviderStateActionBody, + signal: AbortSignal, +) { + switch (body.action) { + case "overwrite-secret": { + return await overwriteModelProviderSecret(db, body, signal); + } + } +} + +const mutateModelProviderState$ = command( + async ({ get, set }, signal: AbortSignal) => { + if (!isTestEndpointAllowed(get(request$))) { + return testEndpointNotFoundResponse(); + } + + const bodyResult = await get(actionBody$); + signal.throwIfAborted(); + if (!bodyResult.ok) { + return bodyResult.response; + } + + return await mutateModelProviderState( + set(writeDb$), + bodyResult.data, + signal, + ); + }, +); + +export const testModelProviderStateRoutes: readonly RouteEntry[] = [ + { + route: testModelProviderStateContract.action, + handler: mutateModelProviderState$, + }, +]; diff --git a/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts b/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts index 18cdc13ac70..271b10df0ba 100644 --- a/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts +++ b/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts @@ -312,9 +312,7 @@ async function createChatThread(args: { modelSelectionClient.update({ params: { id: args.clientThreadId }, body: { - modelSelection: modelSelectionRequestFromSelection( - args.modelSelection, - ), + model: args.modelSelection.selectedModel, codexServiceTier: "fast", eventId: crypto.randomUUID(), }, diff --git a/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts index f1f8146bcd1..45c3d6cdd08 100644 --- a/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts +++ b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts @@ -86,25 +86,38 @@ describe("chat thread model request compatibility", () => { expect(parsed.data).not.toHaveProperty("modelSelection"); }); - it("rejects legacy thread create bodies pinned to a concrete provider", () => { + it("normalizes legacy thread create bodies pinned to a concrete provider", () => { const parsed = chatThreadsContract.create.body.safeParse({ agentId: "agent-1", modelSelection: legacyProviderPinnedModelSelection, title: "Launch plan", }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toMatchObject({ + agentId: "agent-1", + model: "claude-sonnet-4-6", + title: "Launch plan", + }); + expect(parsed.data).not.toHaveProperty("modelSelection"); }); - it("rejects legacy thread model updates pinned to a concrete provider", () => { + it("normalizes legacy thread model updates pinned to a concrete provider", () => { const parsed = chatThreadModelSelectionContract.update.body.safeParse({ modelSelection: legacyProviderPinnedModelSelection, }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toStrictEqual({ model: "claude-sonnet-4-6" }); }); - it("rejects legacy chat send bodies pinned to a concrete provider", () => { + it("normalizes legacy chat send bodies pinned to a concrete provider", () => { const parsed = chatMessagesContract.send.body.safeParse({ agentId: "agent-1", prompt: "Build a launch plan", @@ -112,7 +125,17 @@ describe("chat thread model request compatibility", () => { modelSelection: legacyProviderPinnedModelSelection, }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toMatchObject({ + agentId: "agent-1", + prompt: "Build a launch plan", + model: "claude-sonnet-4-6", + }); + expect(parsed.data).not.toHaveProperty("modelProvider"); + expect(parsed.data).not.toHaveProperty("modelSelection"); }); }); diff --git a/turbo/packages/api-contracts/src/contracts/chat-threads.ts b/turbo/packages/api-contracts/src/contracts/chat-threads.ts index 23545a361d7..55d543fb36a 100644 --- a/turbo/packages/api-contracts/src/contracts/chat-threads.ts +++ b/turbo/packages/api-contracts/src/contracts/chat-threads.ts @@ -407,26 +407,15 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -type LegacyModelSelectionResult = - | { readonly kind: "model"; readonly model: string | null } - | { readonly kind: "reject" }; - -function legacyModelSelectionModel( - value: unknown, -): LegacyModelSelectionResult | undefined { +function legacyModelSelectionModel(value: unknown): string | null | undefined { if (value === null) { - return { kind: "model", model: null }; + return null; } if (!isRecord(value)) { return undefined; } - if (value.modelProviderId !== MODEL_FIRST_SELECTION_PROVIDER_ID) { - return { kind: "reject" }; - } const selectedModel = value.selectedModel; - return typeof selectedModel === "string" - ? { kind: "model", model: selectedModel } - : undefined; + return typeof selectedModel === "string" ? selectedModel : undefined; } function normalizeLegacyModelSelectionInput( @@ -437,16 +426,13 @@ function normalizeLegacyModelSelectionInput( return value; } const legacyModelSelection = legacyModelSelectionModel(value.modelSelection); - if (legacyModelSelection?.kind === "reject") { - return { ...value, model: value.modelSelection }; - } if ( legacyModelSelection === undefined || - (legacyModelSelection.model === null && !options.allowNull) + (legacyModelSelection === null && !options.allowNull) ) { return value; } - return { ...value, model: legacyModelSelection.model }; + return { ...value, model: legacyModelSelection }; } const chatThreadCreateBodySchema = z.preprocess( diff --git a/turbo/packages/api-contracts/src/contracts/index.ts b/turbo/packages/api-contracts/src/contracts/index.ts index 61b643fcbc6..987f45d00c3 100644 --- a/turbo/packages/api-contracts/src/contracts/index.ts +++ b/turbo/packages/api-contracts/src/contracts/index.ts @@ -354,6 +354,15 @@ export { type TestZeroAgentStateActionResponse, type TestZeroAgentStateContract, } from "./test-zero-agent-state"; +export { + testModelProviderStateActionBodySchema, + testModelProviderStateActionResponseSchema, + testModelProviderStateContract, + testModelProviderStateErrorSchema, + type TestModelProviderStateActionBody, + type TestModelProviderStateActionResponse, + type TestModelProviderStateContract, +} from "./test-model-provider-state"; export { testUsageInsightStateActionBodySchema, testUsageInsightStateActionResponseSchema, diff --git a/turbo/packages/api-contracts/src/contracts/test-model-provider-state.ts b/turbo/packages/api-contracts/src/contracts/test-model-provider-state.ts new file mode 100644 index 00000000000..040c98776aa --- /dev/null +++ b/turbo/packages/api-contracts/src/contracts/test-model-provider-state.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import { initContract } from "./base"; + +const c = initContract(); + +export const testModelProviderStateErrorSchema = z.object({ + error: z.string(), +}); + +export const testModelProviderStateActionBodySchema = z.discriminatedUnion( + "action", + [ + z.object({ + action: z.literal("overwrite-secret"), + provider_id: z.string(), + secret_name: z.string(), + secret: z.string(), + }), + ], +); + +export const testModelProviderStateActionResponseSchema = z.object({ + ok: z.literal(true), +}); + +export const testModelProviderStateContract = c.router({ + action: { + method: "POST", + path: "/api/test/model-provider-state/action", + body: testModelProviderStateActionBodySchema, + responses: { + 200: testModelProviderStateActionResponseSchema, + 400: testModelProviderStateErrorSchema, + 404: z.string(), + }, + summary: "Mutate model provider API test support state", + }, +}); + +export type TestModelProviderStateContract = + typeof testModelProviderStateContract; +export type TestModelProviderStateActionBody = z.infer< + typeof testModelProviderStateActionBodySchema +>; +export type TestModelProviderStateActionResponse = z.infer< + typeof testModelProviderStateActionResponseSchema +>; From 51f53f55f83e0b533e3217d0d881fa44392aa27e Mon Sep 17 00:00:00 2001 From: Lancy Date: Thu, 9 Jul 2026 17:45:17 +0800 Subject: [PATCH 10/10] fix(e2e): replace removed --debug-no-mock-claude flag in t54-0 PR #20723 deleted --debug-no-mock-claude from vm0 run in favor of --real-agent-in-preview, but the earlier main-merge conflict resolution on this branch reintroduced a call to the removed flag, breaking cli-e2e-03-runner in CI. --- e2e/tests/03-runner/t54-vm0-billable-firewall.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/03-runner/t54-vm0-billable-firewall.bats b/e2e/tests/03-runner/t54-vm0-billable-firewall.bats index a4a85340193..fed2d3afa83 100644 --- a/e2e/tests/03-runner/t54-vm0-billable-firewall.bats +++ b/e2e/tests/03-runner/t54-vm0-billable-firewall.bats @@ -68,7 +68,7 @@ teardown_file() { @test "t54-0: BYOK provider — firewall not billable" { run $VM0_CLI run "$RUN_AGENT_NAME" \ --model-provider-type "anthropic-api-key" \ - --debug-no-mock-claude \ + --real-agent-in-preview \ "Reply with exactly: DONE" echo "$output"