diff --git a/e2e/helpers/codex-zero.bash b/e2e/helpers/codex-zero.bash index bae98f0ffe9..428429492d0 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). @@ -189,17 +232,12 @@ send_chat_run_message() { # USE_MOCK_CODEX=true env var causes guest-mock-codex to echo the prompt # verbatim — see crates/runner/src/executor.rs (insert_codex_env) and # guest_mock_codex::build_events. - # 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, realAgentInPreview: true}') + '{agentId: $agentId, prompt: $prompt, model: $selectedModel, hasTextContent: true, realAgentInPreview: 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 390e31df4f9..657630376a1 100644 --- a/e2e/helpers/setup.bash +++ b/e2e/helpers/setup.bash @@ -235,6 +235,30 @@ 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 real_agent_in_preview="${4:-false}" + local payload body + + payload=$(jq -nc \ + --arg agentId "$agent_id" \ + --arg prompt "$prompt" \ + --arg selectedModel "$selected_model" \ + --argjson realAgentInPreview "$real_agent_in_preview" \ + '{agentId: $agentId, prompt: $prompt, model: $selectedModel, hasTextContent: true, realAgentInPreview: $realAgentInPreview}') + + 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 - 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 1c03f2dda3a..fed2d3afa83 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,25 +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 - THREAD_IDS="$THREAD_IDS $LAST_THREAD_ID" - export THREAD_IDS + run $VM0_CLI run "$RUN_AGENT_NAME" \ + --model-provider-type "anthropic-api-key" \ + --real-agent-in-preview \ + "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 $]' @@ -73,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 THREAD_IDS="$THREAD_IDS $LAST_THREAD_ID" 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 5898e066fb2..26881c16a82 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 @@ -48,8 +48,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_"; @@ -163,13 +161,10 @@ async function startChatRun( ? {} : { attachFiles: body.attachFiles }), ...(body.selectedModel === undefined - ? { modelProvider: "anthropic-api-key" } - : { - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: body.selectedModel, - }, - }), + ? body.threadId === undefined + ? { model: "claude-sonnet-4-6" } + : {} + : { model: body.selectedModel }), }, [201], ); @@ -2362,10 +2357,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 9381bc38e6b..4d81f1afc57 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 { @@ -45,6 +44,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, @@ -69,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 CODEX_WEB_IMAGE_UPLOAD_PROMPT_SNIPPET = "zero web upload-file -f "; const API_DISPATCH_ZERO_WEB_CHAT_PRE_CREATE_ACTION_TYPES = [ "api_dispatch_pre_create_zero_web_chat_prepare_normal_send", @@ -174,7 +172,7 @@ interface ChatRunSendBody { readonly threadId?: string; readonly clientThreadId?: string; readonly clientMessageId?: string; - readonly modelSelection?: ModelSelectionRequest; + readonly model?: string; readonly runOptions?: ChatRunOptionsRequest; readonly generationTemplate?: GenerationTemplateRequest; readonly attachFiles?: readonly AttachFile[]; @@ -1403,7 +1401,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, { @@ -1412,9 +1410,15 @@ 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: null, + }, + ]); if (!actor.orgId) { throw new Error("Expected pro-suspend chat actor to have an org"); } @@ -1428,10 +1432,7 @@ describe("CHAT-02: admission without spendable credits", () => { 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]); @@ -1467,7 +1468,7 @@ describe("CHAT-02: admission without spendable credits", () => { }, 60_000); }); -describe("CHAT-02: explicit provider pins", () => { +describe("CHAT-02: model-first provider policies", () => { it("adds Codex image upload guidance for web chat Codex sends", async () => { const { actor, agentId, runnerGroup } = await entitledChatActor(); chatCallbacks.failIfChatCallbackRouteIsFetched(); @@ -1494,10 +1495,7 @@ describe("CHAT-02: explicit provider pins", () => { const run = await sendChatRun(actor, { agentId, prompt: "generate an image in web chat", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel: "gpt-5.4", - }, + model: "gpt-5.4", }); const { claim } = await claimChatRun(runnerGroup, run.runId); const appendSystemPrompt = claim.appendSystemPrompt ?? ""; @@ -1511,21 +1509,27 @@ describe("CHAT-02: explicit provider pins", () => { await cancelChatRun(actor, run.runId); }); - it("routes explicit provider pins into the runner claim", async () => { + 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( @@ -1596,16 +1600,19 @@ describe("CHAT-02: explicit provider pins", () => { // 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: null, + }, + ]); 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) { @@ -1663,10 +1670,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], @@ -1684,10 +1688,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( @@ -1712,10 +1713,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" }, ); @@ -1727,15 +1725,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(); @@ -1760,10 +1752,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, @@ -1784,10 +1773,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], @@ -1806,14 +1792,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( @@ -1913,17 +1905,20 @@ describe("CHAT-02: explicit provider pins", () => { }; 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: null, + }, + ]); 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; @@ -1965,17 +1960,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: null, + }, + ]); 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( @@ -2015,6 +2013,59 @@ describe("CHAT-02: explicit provider pins", () => { model: "glm-5.2", }); }, 90_000); + it("rejects legacy blank OpenRouter provider secrets during firewall auth", async () => { + const fw = createFirewallApi(context); + const { actor, agentId, runnerGroup } = await entitledChatActor(); + const { providerId } = await upsertOrgModelProvider(actor, { + 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 overwriteModelProviderSecretForTests(context.signal, { + providerId, + secretName: "OPENROUTER_API_KEY", + secret: " ", + }); + + const run = await sendChatRun(actor, { + agentId, + prompt: "run with a legacy blank openrouter provider", + model: "claude-opus-4-7", + }); + const { claim, sandboxHeaders } = await claimChatRun( + runnerGroup, + run.runId, + ); + if (!claim.encryptedSecrets) { + throw new Error("Expected OpenRouter claim to carry encrypted secrets"); + } + + const rejected = await fw.requestFirewallAuth( + sandboxHeaders, + { + encryptedSecrets: claim.encryptedSecrets, + authHeaders: { + Authorization: `Bearer ${secretTemplate("OPENROUTER_API_KEY")}`, + }, + secretConnectorMap: claim.secretConnectorMap ?? undefined, + secretConnectorMetadataMap: + claim.secretConnectorMetadataMap ?? undefined, + }, + [424], + ); + expectApiError(rejected.body); + expect(rejected.body.error.code).toBe("CONNECTOR_NOT_CONFIGURED"); + + await api.requestCancelRun(actor, run.runId, [200]); + }, 60_000); }); describe("CHAT-02: run-level model overrides", () => { @@ -2043,10 +2094,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( @@ -2076,10 +2124,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 ?? ""; @@ -2125,10 +2170,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([]); @@ -2183,34 +2225,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, @@ -2218,15 +2233,14 @@ 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], ); 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. @@ -2241,17 +2255,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 f3bd3d76d0e..7561a0696b4 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 @@ -70,8 +70,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; @@ -160,10 +158,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]); @@ -580,10 +575,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 }, ); @@ -752,10 +744,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(); @@ -818,10 +807,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); @@ -840,10 +826,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); @@ -874,10 +857,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 [ @@ -889,10 +869,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); @@ -907,32 +884,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 c510ae5ec8d..c4dde25a484 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 @@ -26,7 +26,6 @@ import { type ChatRunOptionsRequest, type CodexServiceTier, type GenerationTemplateRequest, - type ModelSelectionRequest, type PagedChatMessage, type PersistedAttachment, } from "@vm0/api-contracts/contracts/chat-threads"; @@ -97,14 +96,8 @@ 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"; - -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"; @@ -172,8 +165,7 @@ type BddSendMessageBody = readonly prompt: string; readonly threadId?: string; readonly clientThreadId?: string; - readonly modelProvider?: string; - readonly modelSelection?: ModelSelectionRequest | null; + readonly model?: string; readonly runOptions?: ChatRunOptionsRequest; readonly generationTemplate?: GenerationTemplateRequest; readonly hasTextContent?: boolean; @@ -456,16 +448,20 @@ export function createChatFilesBddApi(context: TestContext) { readonly title?: string; readonly clientThreadId?: string; readonly eventId?: string; - readonly modelSelection?: ModelSelectionRequest; + readonly model?: string; }, ): Promise<{ readonly id: string; readonly title: string | null }> { const response = await accept( threadsClient().create({ headers: authenticate(context, actor), body: { - ...body, - modelSelection: - body.modelSelection ?? defaultCreateThreadModelSelection(), + 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], @@ -480,7 +476,7 @@ export function createChatFilesBddApi(context: TestContext) { readonly title?: string; readonly clientThreadId?: string; readonly eventId?: string; - readonly modelSelection?: ModelSelectionRequest; + readonly model?: string; }, statuses: readonly (201 | 401 | 402 | 404)[], ) { @@ -488,9 +484,13 @@ export function createChatFilesBddApi(context: TestContext) { threadsClient().create({ headers: authenticate(context, actor), body: { - ...body, - modelSelection: - body.modelSelection ?? defaultCreateThreadModelSelection(), + 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, @@ -841,7 +841,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; @@ -852,7 +852,7 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), params: { id: threadId }, body: { - modelSelection, + model, codexServiceTier: options?.codexServiceTier, eventId: options?.eventId, }, @@ -864,7 +864,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; @@ -876,7 +876,7 @@ export function createChatFilesBddApi(context: TestContext) { headers: authenticate(context, actor), params: { id: threadId }, body: { - modelSelection, + model, codexServiceTier: options?.codexServiceTier, eventId: options?.eventId, }, @@ -1182,12 +1182,11 @@ 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 + ? defaultCreateThreadModel() + : undefined; + const selectedModel = body.model ?? defaultModel; return { agentId: body.agentId, prompt: body.prompt, @@ -1197,10 +1196,9 @@ export function createChatFilesBddApi(context: TestContext) { ...(body.clientThreadId === undefined ? {} : { clientThreadId: body.clientThreadId }), - ...(body.modelProvider === undefined + ...(selectedModel === undefined ? {} - : { modelProvider: body.modelProvider }), - ...(modelSelection === undefined ? {} : { modelSelection }), + : { model: selectedModel }), ...(body.runOptions === undefined ? {} : { runOptions: body.runOptions }), 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__/run-lifecycle.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts index e51dcbd1b6c..c3c1150dca8 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 @@ -72,10 +72,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 CODEX_WEB_IMAGE_UPLOAD_PROMPT_SNIPPET = "zero web upload-file -f "; const API_DISPATCH_QUEUE_PERSISTENCE_ACTION_TYPES = [ "api_dispatch_persist_custom_connector_auth_refs", @@ -936,7 +932,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) { @@ -3358,17 +3357,29 @@ 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, { agentId, prompt: "vm0 managed minimax codex provider", - modelProvider: "vm0", - modelSelection: { - modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, - selectedModel, - }, + model: selectedModel, }, [201], ); @@ -3505,10 +3516,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 22dccda4edc..ec1d3fedfad 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 @@ -37,8 +37,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 = @@ -393,10 +391,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 32cd8a74fb7..b51d742933b 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"; @@ -93,10 +92,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], @@ -130,10 +126,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/__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/api/src/signals/routes/zero-chat-messages.ts b/turbo/apps/api/src/signals/routes/zero-chat-messages.ts index 79064d19443..271a985796b 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,7 @@ interface NormalSendBody { readonly clientThreadId?: string; readonly chatThreadEventId?: string; readonly chatThreadSortEventId?: string; - readonly modelProvider?: string; + readonly model?: string; readonly modelSelection?: { readonly modelProviderId: string; readonly selectedModel: string; @@ -463,6 +463,33 @@ 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) { + return { ok: true, data: body }; + } + return { + ok: true, + data: { + ...body, + modelSelection: modelFirstSelection(body.model), + }, + }; +} + function hasAgentSessionId( value: unknown, ): value is { readonly agentSessionId: string } { @@ -1251,7 +1278,7 @@ async function validateCodexServiceTierBeforeThread(params: { orgId: params.orgId, userId: params.userId, modelPin, - requestedModelProvider: requestedModelProviderFor(params.body), + requestedModelProvider: undefined, }); const codexServiceTierError = validateCodexServiceTier({ body: params.body, @@ -2644,12 +2671,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; @@ -2820,7 +2841,7 @@ const createNormalChatRun$ = command( args, prepared, modelPin, - requestedModelProvider: requestedModelProviderFor(args.body), + requestedModelProvider: undefined, }); signal.throwIfAborted(); if (providerAdmission.error) { @@ -3037,13 +3058,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..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 @@ -1,5 +1,8 @@ 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"; @@ -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$); @@ -37,7 +47,7 @@ const createInner$ = command(async ({ get, set }, signal: AbortSignal) => { db: set(writeDb$), orgId: auth.orgId, userId: auth.userId, - modelSelection: body.data.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 0f4722b545b..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 @@ -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,13 @@ const modelSelectionBody$ = bodyResultOf( chatThreadModelSelectionContract.update, ); +function modelFirstSelection(selectedModel: string) { + return { + modelProviderId: MODEL_FIRST_SELECTION_PROVIDER_ID, + selectedModel, + }; +} + function isCodexFastServiceTierModel( model: string | null | undefined, ): boolean { @@ -86,12 +96,14 @@ const updateModelSelectionInner$ = command( } const writeDb = set(writeDb$); - const pin = body.data.modelSelection + const modelSelection = + body.data.model === null ? null : modelFirstSelection(body.data.model); + 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/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"); 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 2ba93b0c6a1..2d1d573a86c 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/signals/chat-page/optimistic-chat-thread-page.ts b/turbo/apps/platform/src/signals/chat-page/optimistic-chat-thread-page.ts index 5cfe36121ad..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 @@ -45,10 +45,7 @@ import { featureSwitch$ } from "../external/feature-switch.ts"; import { codexFastModeLocalDefault$ } from "../zero-page/codex-fast-local-default.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"; @@ -299,9 +296,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 }, @@ -317,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/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 7045e605726..1e083f26850 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-composer-models-and-templates.test.tsx b/turbo/apps/platform/src/views/zero-page/__tests__/chat-composer-models-and-templates.test.tsx index 1fff0947f32..b5441902f57 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 @@ -1116,19 +1116,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; @@ -1180,11 +1174,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 412ce806530..cabde81a5e9 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 834cda9c410..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 @@ -15,10 +15,10 @@ import { chatThreadModelSelectionContract, chatThreadMessagesContract, chatMessagesContract, + MODEL_FIRST_SELECTION_PROVIDER_ID, type ChatRunOptionsRequest, type CodexServiceTier, type GenerationTemplateRequest, - type ModelSelectionRequest, type PagedChatMessage, type PersistedAttachment, } from "@vm0/api-contracts/contracts/chat-threads"; @@ -44,6 +44,27 @@ 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, + selectedModel, + }; +} + +function modelSelectionFromBody(body: { + readonly model?: string | null; +}): ModelSelectionRequest | null | undefined { + 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,7 +674,7 @@ export function mockChatLifecycle( clientMessageId?: string; hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; - modelSelection?: ModelSelectionRequest | null; + model?: string; runOptions?: ChatRunOptionsRequest; }) => { const clientMessageId = body.clientMessageId ?? crypto.randomUUID(); @@ -659,13 +684,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,7 +720,7 @@ export function mockChatLifecycle( }[]; hasTextContent?: boolean; generationTemplate?: GenerationTemplateRequest; - modelSelection?: ModelSelectionRequest | null; + model?: string; runOptions?: ChatRunOptionsRequest; computerUseHostId?: string | null; revokesMessageId?: string; @@ -706,8 +732,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 +839,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 +889,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 +917,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/__tests__/chat-threads.test.ts b/turbo/packages/api-contracts/src/contracts/__tests__/chat-threads.test.ts index 62f01e3aa6d..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 @@ -4,9 +4,141 @@ import { artifactItemSchema, artifactsContract, 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", +}; + +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({ + 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"); + }); + + 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(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 updates pinned to a concrete provider", () => { + const parsed = chatThreadModelSelectionContract.update.body.safeParse({ + modelSelection: legacyProviderPinnedModelSelection, + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data).toStrictEqual({ model: "claude-sonnet-4-6" }); + }); + + 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", + modelProvider: "anthropic-api-key", + modelSelection: legacyProviderPinnedModelSelection, + }); + + 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 e4644a4c448..55d543fb36a 100644 --- a/turbo/packages/api-contracts/src/contracts/chat-threads.ts +++ b/turbo/packages/api-contracts/src/contracts/chat-threads.ts @@ -391,33 +391,123 @@ const chatThreadDraftSchema = z.object({ draftAttachments: z.array(persistedAttachmentSchema).nullable(), }); -/** - * 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. - */ -const modelSelectionRequestSchema = z - .object({ - modelProviderId: z.string().uuid(), - selectedModel: z.string().min(1), - }) +const selectedModelRequestSchema = z + .string() + .min(1) .superRefine((value, ctx) => { - if ( - value.modelProviderId === MODEL_FIRST_SELECTION_PROVIDER_ID && - !isSupportedRunModel(value.selectedModel) - ) { + if (!isSupportedRunModel(value)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["selectedModel"], message: "Invalid model selection", }); } }); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +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 legacyModelSelection = legacyModelSelectionModel(value.modelSelection); + if ( + legacyModelSelection === undefined || + (legacyModelSelection === null && !options.allowNull) + ) { + return value; + } + return { ...value, model: legacyModelSelection }; +} + +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(), + // Preview evaluation escape hatch: when enabled, the request asks the + // runner to bypass preview mock CLIs and use the real agent runtime. + realAgentInPreview: z.boolean().optional(), + revokesMessageId: z.string().min(1).optional(), + interruptsRunId: z.undefined().optional(), + }), +); + /** * Chat thread collection route contract. */ @@ -471,13 +561,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(), @@ -779,11 +863,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,42 +910,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(), - 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`. - * - * When 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(), - // 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(), - // Preview evaluation escape hatch: when enabled, the request asks the - // runner to bypass preview mock CLIs and use the real agent runtime. - realAgentInPreview: 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), @@ -875,8 +920,7 @@ export const chatMessagesContract = c.router({ clientThreadId: z.undefined().optional(), chatThreadEventId: z.undefined().optional(), chatThreadSortEventId: z.undefined().optional(), - modelProvider: z.undefined().optional(), - modelSelection: z.undefined().optional(), + model: z.undefined().optional(), runOptions: z.undefined().optional(), generationTemplate: z.undefined().optional(), computerUseHostId: z.undefined().optional(), @@ -894,8 +938,7 @@ export const chatMessagesContract = c.router({ clientThreadId: z.undefined().optional(), chatThreadEventId: z.undefined().optional(), chatThreadSortEventId: z.undefined().optional(), - modelProvider: z.undefined().optional(), - modelSelection: z.undefined().optional(), + model: z.undefined().optional(), runOptions: z.undefined().optional(), generationTemplate: z.undefined().optional(), computerUseHostId: z.undefined().optional(), @@ -1191,7 +1234,6 @@ export { chatThreadDetailSchema, chatThreadMetadataSchema, chatThreadDraftSchema, - modelSelectionRequestSchema, chatRunOptionsRequestSchema, generationTemplateRequestSchema, presentationGenerationTemplateRequestSchema, @@ -1212,7 +1254,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 b1affb7e4fc..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, @@ -618,7 +627,6 @@ export { chatThreadMetadataSchema, chatThreadDraftSchema, MODEL_FIRST_SELECTION_PROVIDER_ID, - modelSelectionRequestSchema, generationTemplateRequestSchema, presentationGenerationTemplateRequestSchema, websiteGenerationTemplateRequestSchema, @@ -633,7 +641,6 @@ export { chatThreadArtifactGoogleDriveSyncSchema, chatThreadArtifactRunSchema, htmlArtifactEditSnapshotSchema, - type ModelSelectionRequest, type GenerationTemplateRequest, type PresentationGenerationTemplateRequest, type WebsiteGenerationTemplateRequest, 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 +>; diff --git a/turbo/packages/core/src/index.ts b/turbo/packages/core/src/index.ts index 0696f071f8c..daf46e0de52 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,