feat: flow chat model picker on a shared model-settings component (#11187)

* refactor: render the session chat model menu from a shared ChatModelSettings config

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: pick the flow chat's model and thinking from the provider fields the flow exposes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: name only the thinking level the flow run will send on the model button

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let the flow chat take a typed model id and keep a shared thinking input editable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: promote a flow input to the model button only where its control can edit it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop any reasoning token the chosen model rejects before a flow chat run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-17 09:59:05 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 68f2248018
commit 189793c2e4
21 changed files with 2510 additions and 324 deletions
+20 -5
View File
@@ -5276,15 +5276,26 @@ tool, \`websearch\` for web search.
}
\`\`\`
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
agent through \`value.agent\`
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
fields to flow inputs instead see below
### Chat-Mode Flows
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
input, read by the agent. Any other flow input the composer does not edit itself is asked for
under Configure inputs.
**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs
instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`)
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer a provider picker, a model list, a thinking
control and a field left static is fixed, with no control drawn for it. \`kind\` is the one
exception: the composer writes it only together with \`resource\`, since a provider is picked as a
pair, so a \`kind\` input wired on its own stays askable under Configure inputs and nothing the run
needs becomes unreachable.
\`\`\`json
{
@@ -5293,8 +5304,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
@@ -5307,6 +5318,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
}
\`\`\`
- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\`
references. A spread, a call or a computed key leaves the composer unable to tell which input
feeds which field, so it offers no control at all a bare \`flow_input.x\` for the whole object
is read instead as that one input carrying every field
- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing
- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once
- \`user_attachments\` points at a flow input typed as an array of s3 objects
@@ -27,7 +27,7 @@
let capability = $derived(
provider && model
? getReasoningCapability(provider, model)
: { supported: false, levels: [], canDisable: false }
: { supported: false, levels: [], canDisable: false, known: false }
)
// The token that turns reasoning off on a model that reasons by default
@@ -473,6 +473,7 @@
hideSidebar={true}
path={$pathStore}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
@@ -0,0 +1,314 @@
<script lang="ts">
/**
* The model button every chat puts in the bottom-right of its composer: the trigger
* names the model and its reasoning effort, and the menu holds the choices behind
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
* chat render the same control from different data — see chatModelSettings.ts.
*/
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
import { getReasoningCapability, resolveEffectiveReasoning } from './reasoningRegistry'
import {
fixedReasoningReason,
reasoningControlState,
reasoningDisplay,
type ChatModelSettingsConfig,
type ChoiceSection
} from './chatModelSettings'
import type { Item } from '$lib/utils'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
type MeltItem = MenubarMenuElements['item']
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
let { config }: { config: ChatModelSettingsConfig } = $props()
const reasoning = $derived(config.reasoning)
const capability = $derived(
reasoning?.provider && reasoning.model
? getReasoningCapability(reasoning.provider, reasoning.model)
: { supported: false, levels: [] as string[], canDisable: false, known: false }
)
const controlState = $derived(reasoningControlState(reasoning, capability))
const fixedReason = $derived(fixedReasoningReason(reasoning, capability))
// Effective effort accounts for the default-on level on capable models.
const effective = $derived(
reasoning?.provider && reasoning.model
? resolveEffectiveReasoning({
provider: reasoning.provider,
model: reasoning.model,
reasoning: reasoning.value
})
: undefined
)
// The stops, the one in use and the trigger's suffix are decided together, in one tested
// place: a stop the slider shows as `off` must not read as the provider's `none` on the button.
const display = $derived(reasoningDisplay(reasoning, capability, effective))
const stops = $derived(display.stops)
const currentStop = $derived(display.currentStop)
const effortLabel = $derived(display.label)
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
// The trigger label resizes when the effort changes (dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize
// would shift the popover, so freeze the trigger to its width at open time and release it
// on close — no movement while open, natural sizing the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
// one rendered must not draw one above itself whichever block that turns out to be.
const BLOCK_CLASS =
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
const ROW_CLASS =
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
</script>
{#snippet trigger()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
disabled={config.readOnly}
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title={config.readOnly ? config.readOnlyReason : config.title}
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{config.label}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if config.badge}
<span
class={twMerge(
'shrink-0 rounded-full px-1.5 text-2xs',
config.badge.warn
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'
)}>{config.badge.text}</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet typedField(
value: string,
placeholder: string,
onCommit: (value: string) => void,
close: () => void
)}
{#key value}
<TextInput
size="sm"
{value}
inputProps={{
placeholder,
onchange: (e) => onCommit(e.currentTarget.value.trim()),
// Capture, not bubble: Svelte delegates `keydown` to the root, which sits above the
// menu — so a bubble handler here would run only after melt's own listener had read
// the key as typeahead and moved focus. A capture key is not delegatable, so this
// becomes a real listener on the input and sees the event first.
onkeydowncapture: (e) => {
// Escape cancels: let it reach the menu with the value untouched.
if (e.key === 'Escape') return
// Tab closes the menu, unmounting this field before focus moves, so no change
// event would ever fire. Commit on the way past.
if (e.key === 'Tab') {
onCommit(e.currentTarget.value.trim())
return
}
// Enter means done: commit and close, rather than leaving the menu open around a
// field the commit is about to rebuild.
if (e.key === 'Enter') {
e.preventDefault()
onCommit(e.currentTarget.value.trim())
close()
return
}
// Everything else is typing; the menu reads loose keys as typeahead.
e.stopPropagation()
}
}}
/>
{/key}
{/snippet}
{#snippet section(sec: ChoiceSection, item: MeltItem, close: () => void)}
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
{#if sec.loading}
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading...
</div>
{:else if sec.options.length === 0}
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
{:else}
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
{#each sec.options as option (option.key)}
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
<span class="truncate grow min-w-0">{option.label}</span>
{#if option.hint}
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
{/if}
{#if option.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
{/if}
{#if sec.custom && !sec.loading}
{@const custom = sec.custom}
<div class="px-3 pt-1 pb-1.5">
{@render typedField(
'',
custom.placeholder,
(value) => {
if (value) custom.onCommit(value)
},
close
)}
</div>
{/if}
{/snippet}
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
{#each items.filter((row) => !row.hide) as row (row.displayName)}
{#if row.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if row.submenuItems}
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
{:else}
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
{#if row.icon}
<row.icon size={14} class="shrink-0" />
{/if}
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
{#if row.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/if}
{/each}
{/snippet}
{#if config.readOnly}
{@render trigger()}
{:else}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
{@render trigger()}
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
{#if config.topItems}
<div class={BLOCK_CLASS}>
{@render rows(config.topItems(close), item, builders)}
</div>
{/if}
{#each config.sections ?? [] as sec (sec.label)}
<div class={BLOCK_CLASS}>
{@render section(sec, item, close)}
</div>
{/each}
{#if reasoning}
<div class={BLOCK_CLASS}>
{#if controlState === 'fixed'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason={fixedReason}
/>
{:else if controlState === 'awaiting-model'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Pick a model first"
/>
{:else if controlState === 'unknown'}
<!-- No rules for this provider, so no ladder to offer. The flow still takes a
token, so it is typed rather than picked: claiming the model cannot think
would be a guess, and offering nothing would leave it settable nowhere. -->
<div class="px-3 pt-1 pb-1.5">
<div class="text-2xs uppercase tracking-wide text-secondary mb-1">Thinking</div>
{@render typedField(reasoning.value ?? '', 'none', reasoning.onSelect, close)}
<div class="text-2xs text-tertiary mt-1">
Windmill has no thinking levels for this provider — type what it accepts.
</div>
</div>
{:else if controlState === 'ladder'}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the row
above. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper
{item}
onKeydown={(e) => effortSlider?.adjust(e)}
class="block group"
>
<ReasoningEffortSlider
bind:this={effortSlider}
{stops}
current={currentStop}
onSelect={reasoning.onSelect}
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
overrideLabel={stops.includes(currentStop) ? undefined : effortLabel}
/>
</MenuItemWrapper>
{:else}
<!-- Kept in place rather than dropped: the row saying the model cannot think
is the answer to why there is no slider. -->
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
</div>
{/if}
{#if config.bottomItems}
<div class={BLOCK_CLASS}>
{@render rows(config.bottomItems(close), item, builders)}
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
/**
* The reasoning-effort control: a thin slider over a model's ordered effort stops.
*
* Presentational on purpose. Callers keep their own value convention — the copilot's
* REASONING_OFF sentinel and an agent's `reasoning_effort` token mean off in
* different ways — and hand this component a resolved list of stops plus the current
* one, so the two never have to agree on anything but the ordering.
*/
interface Props {
/** Ordered stops, least effort first. Fewer than two renders no slider. */
stops: string[]
current: string
onSelect: (stop: string) => void
/** When set, the section renders disabled with this as the explanation. */
unsupportedReason?: string
/** Display name for a stop whose value is a provider sentinel rather than a word. */
format?: (stop: string) => string
/** Shown in place of the current stop — a state the slider has no position for. */
overrideLabel?: string
}
let {
stops,
current,
onSelect,
unsupportedReason,
format = (stop: string) => stop,
overrideLabel
}: Props = $props()
/**
* A `current` naming no stop is a real state, not a missing one: an agent that leaves the
* effort unset sends nothing and the provider decides. Three things follow, and they only
* hold together.
*
* The thumb rests at the start, because a range input always has one somewhere, and
* `overrideLabel` is what tells the reader this is not the lowest stop. The track is
* unfilled there, which index 0 gives for free. And since the input's value already reads
* 0, picking the lowest stop by pointer fires no `input` event — so a click has to be
* committed explicitly, or that stop is reachable only by keyboard.
*/
const hasPosition = $derived(stops.indexOf(current) >= 0)
const stopIndex = $derived(Math.max(0, stops.indexOf(current)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
const fillPct = $derived(
stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0
)
/** Left/right stepping, for a caller that owns the keyboard (a melt menu item). */
export function adjust(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
onSelect(stops[next])
}
// Melt's roving focus blurs the focused element on pointermove, which aborts a native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
}
}
}
</script>
{#if unsupportedReason}
<!-- Kept visible rather than hidden: the absence of the control is itself the answer,
but only if it says why. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">{unsupportedReason}</div>
</div>
{:else}
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{overrideLabel ?? format(current)}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects an enclosing menu item's highlight, not the header. -->
<div class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover">
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => onSelect(stops[+e.currentTarget.value])}
onclick={(e) => {
// `click`, not `pointerup`: it is the event that means pressed and released on
// the track, so a press that began on the row above cannot commit an effort
// nobody chose. Only the click that moved nothing — any other stop has already
// committed through `oninput`, and doing it again would write it twice.
if (!hasPosition && +e.currentTarget.value === stopIndex) {
onSelect(stops[stopIndex])
}
}}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
{/if}
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -1,10 +1,12 @@
<script lang="ts">
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
/**
* The session chat's model button: a fixed ChatModelSettings config over the copilot's
* own state — the workspace's configured models, the session's model/effort selection
* and its localStorage pins, the custom-prompt editors, and the free-tier grant.
*/
import { User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import ChatModelSettings from '../ChatModelSettings.svelte'
import { carriedReasoning, type ChatModelSettingsConfig } from '../chatModelSettings'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
@@ -28,7 +30,6 @@
import { thinkingPreferences } from './thinkingPreferences.svelte'
import {
getReasoningCapability,
resolveEffectiveReasoning,
REASONING_OFF,
type ReasoningProviderModel
} from '../reasoningRegistry'
@@ -60,57 +61,16 @@
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
// Effective effort accounts for the default-on level on capable models.
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
// Slider stops: an off position only where the model can truly disable (else the
// provider would coerce it to the lowest level), then the provider-native levels.
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
let currentStop = $derived(
providerModel.reasoning === REASONING_OFF
? REASONING_OFF
: (currentEffort ?? stops[stops.length - 1])
)
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
// for models with no reasoning support.
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
function selectModel(m: AIProviderModel) {
// Carry the effort onto the new model only if it supports that level ('off'
// only where the model can truly disable); otherwise drop it so the model's
// default applies.
const carried = providerModel.reasoning
const cap = getReasoningCapability(m.provider, m.model)
const keep =
carried === REASONING_OFF
? cap.canDisable
: carried !== undefined && cap.levels.includes(carried)
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
const keep = carriedReasoning(
providerModel.reasoning,
REASONING_OFF,
getReasoningCapability(m.provider, m.model)
)
$copilotSessionModel = { ...m, ...(keep !== undefined ? { reasoning: keep } : {}) }
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep)
}
function selectReasoning(value: string) {
@@ -233,9 +193,8 @@
}
}
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
// Prompt parameters, surfaced as a melt submenu. The menu keeps itself open on item
// click, so these actions close it explicitly before opening a modal.
function paramItems(close: () => void): Item {
return {
displayName: 'Parameters',
@@ -270,155 +229,56 @@
}
}
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
// roving focus blurs the focused element on pointermove, which would abort the native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
const config = $derived<ChatModelSettingsConfig>({
label: providerModel.model,
title: 'Model & reasoning settings',
badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined,
// Off in a session: the assistant settings modal's Instructions section owns the
// prompt entries there, so the menu would offer the same thing twice.
topItems: promptSettings ? (close) => [paramItems(close)] : undefined,
sections: [
{
label: 'Model',
options: models.map((m) => ({
key: `${m.provider}/${m.model}`,
label: m.model,
selected: m.model === providerModel.model && m.provider === providerModel.provider,
onSelect: () => selectModel(m)
}))
}
}
}
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
function adjustEffort(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
selectReasoning(stops[next])
}
],
reasoning: {
provider: providerModel.provider as AIProvider,
model: providerModel.model,
value: providerModel.reasoning,
offToken: REASONING_OFF,
// The copilot fills an unset effort in before it calls the provider, so unset
// really does run at the default level and the button may name it.
sendsDefaultWhenUnset: true,
// The session chat's model is always its own to change.
writable: true,
typedWhenUnknown: false,
onSelect: selectReasoning
},
// A reading preference rather than a model parameter: it applies to every chat in
// this browser, including thinking already in the transcript. No close(): flipping
// it should not dismiss the menu.
bottomItems: () => [
{
displayName: 'Always expand thinking',
selected: thinkingPreferences.expandByDefault,
action: () => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)
}
]
})
</script>
{#snippet externalLinkIcon()}
<ExternalLink size={14} class="shrink-0 text-secondary" />
{/snippet}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
endIcon={{ icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title="Model & reasoning settings"
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{providerModel.model}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
{#if promptSettings}
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
{/if}
<ChatModelSettings {config} />
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="max-h-48 overflow-y-auto">
{#each models as m (m.provider + m.model)}
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => selectModel(m)}
>
<span class="truncate grow min-w-0">{m.model}</span>
{#if m.model === providerModel.model && m.provider === providerModel.provider}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
<div class="my-1 border-t border-border-light"></div>
{#if capability.supported}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the Parameters
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects the item's highlight, not the header. -->
<div
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
>
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
</MenuItemWrapper>
{:else}
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
</div>
{/if}
<!-- A reading preference rather than a model parameter: it applies to every
chat in this browser, including thinking already in the transcript. -->
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
>
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
{#if thinkingPreferences.expandByDefault}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
</div>
{/snippet}
</DropdownV2>
<!-- Only where the entries that open it are rendered. -->
{#if promptSettings}
<AIPromptsModal
bind:open={modalOpen}
@@ -436,61 +296,3 @@
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
{/if}
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest'
import {
carriedReasoning,
fixedReasoningReason,
reasoningControlState,
reasoningDisplay,
REASONING_PROVIDER_DEFAULT,
type ChatModelSettingsReasoning
} from './chatModelSettings'
import {
getReasoningCapability,
REASONING_OFF,
resolveEffectiveReasoning
} from './reasoningRegistry'
/**
* The trigger's suffix and the slider's stops are read side by side, so they have to agree
* about one value — the provider-native off token must not read as `none` on one and `off`
* on the other, and an effort the run does not send must not be named at all.
*/
function display(
reasoning: Partial<ChatModelSettingsReasoning> & { provider: any; model: string }
) {
const full = {
value: undefined,
offToken: undefined,
sendsDefaultWhenUnset: false,
onSelect: () => {},
...reasoning
} as ChatModelSettingsReasoning
const capability = getReasoningCapability(full.provider, full.model)
// Composed exactly as the component composes it, so the test exercises the real pair.
const effective = resolveEffectiveReasoning({
provider: full.provider,
model: full.model,
reasoning: full.value
})
return reasoningDisplay(full, capability, effective)
}
describe('reasoningDisplay', () => {
it('says nothing for a model that cannot reason', () => {
const shown = display({ provider: 'openai', model: 'gpt-4o' })
expect(shown.label).toBeUndefined()
expect(shown.stops).toEqual([])
})
// The session chat's own sentinel: what it stores is already the word the reader sees.
it('reads the session chat off sentinel as off', () => {
const shown = display({
provider: 'openai',
model: 'gpt-5.1',
offToken: REASONING_OFF,
value: REASONING_OFF,
sendsDefaultWhenUnset: true
})
expect(shown.label).toBe(REASONING_OFF)
expect(shown.currentStop).toBe(REASONING_OFF)
})
// An agent writes the provider's own token, which can read as anything.
it('reads a provider-native off token as off too', () => {
const shown = display({
provider: 'openai',
model: 'gpt-5.1',
offToken: 'none',
value: 'none'
})
expect(shown.label).toBe(REASONING_OFF)
expect(shown.currentStop).toBe('none')
expect(shown.stops[0]).toBe('none')
})
it('names the level a chat that fills one in will send', () => {
const shown = display({
provider: 'openai',
model: 'gpt-5.1',
offToken: REASONING_OFF,
value: undefined,
sendsDefaultWhenUnset: true
})
expect(shown.label).toBe('high')
})
// An agent step omits the field, so naming a level would claim something untrue.
it('names no level where an unset effort is simply not sent', () => {
const shown = display({
provider: 'anthropic',
model: 'claude-sonnet-5',
offToken: 'none',
value: undefined
})
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
expect(shown.currentStop).toBe('')
})
// Claude 4.x only thinks when asked, so an absent effort is already off — and the flow
// chat must be able to get back to it after a level has been picked.
it('offers omission as the off stop where that is how the model disables', () => {
const unset = display({ provider: 'anthropic', model: 'claude-opus-4-6', offToken: '' })
expect(unset.label).toBe(REASONING_OFF)
expect(unset.stops[0]).toBe('')
const picked = display({
provider: 'anthropic',
model: 'claude-opus-4-6',
offToken: '',
value: 'high'
})
expect(picked.currentStop).toBe('high')
expect(picked.stops).toContain('')
})
// gpt-5 reasons at medium with no effort sent, so an empty off token buys no off stop.
it('offers no off where the model cannot stop thinking', () => {
const shown = display({ provider: 'openai', model: 'gpt-5', offToken: '' })
expect(shown.stops).not.toContain('')
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
})
// The run sends an explicitly set effort whatever the model, so a token typed against a
// provider we have no rules for has to reach the trigger — silence would hide it.
it('names a set effort even where it can offer no ladder', () => {
const shown = display({ provider: 'customai', model: 'deepseek-r1', value: 'high' })
expect(shown.label).toBe('high')
expect(shown.stops).toEqual([])
})
})
describe('carriedReasoning', () => {
const cap = (model: string) => getReasoningCapability('openai', model)
// A level carried onto a model that cannot think stays in the flow input, and the run sends it.
it('drops a level the new model does not have', () => {
expect(carriedReasoning('high', '', cap('gpt-4o'))).toBeUndefined()
expect(carriedReasoning('xhigh', '', cap('gpt-5.1'))).toBeUndefined()
})
it('keeps a level the new model does have', () => {
expect(carriedReasoning('high', '', cap('gpt-5.1'))).toBe('high')
})
it('carries off only onto a model that can truly stop thinking', () => {
expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5.1'))).toBe(REASONING_OFF)
expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5'))).toBeUndefined()
})
// A provider the registry has no rules for draws no thinking control, so a carried level
// would be invisible and unclearable — and still sent, since an explicitly set effort
// goes out whatever the model.
it('drops the effort where it has no rules for the provider', () => {
expect(
carriedReasoning('high', REASONING_OFF, getReasoningCapability('customai', 'deepseek-r1'))
).toBeUndefined()
})
it('has nothing to carry when no effort is set', () => {
expect(carriedReasoning(undefined, '', cap('gpt-5.1'))).toBeUndefined()
expect(carriedReasoning('', '', cap('gpt-5.1'))).toBeUndefined()
})
})
const asReasoning = (over: Partial<ChatModelSettingsReasoning>): ChatModelSettingsReasoning =>
({
provider: 'openai',
model: 'gpt-5.1',
value: undefined,
offToken: REASONING_OFF,
sendsDefaultWhenUnset: false,
writable: true,
typedWhenUnknown: true,
onSelect: () => {},
...over
}) as ChatModelSettingsReasoning
/** The control is always drawn; this is the only thing that decides what it draws. */
describe('reasoningControlState', () => {
const cap = (model: string) => getReasoningCapability('openai', model)
it('shows the ladder for a model with levels', () => {
expect(reasoningControlState(asReasoning({}), cap('gpt-5.1'))).toBe('ladder')
})
it('says a model cannot think when the registry knows it cannot', () => {
expect(reasoningControlState(asReasoning({ model: 'gpt-4o' }), cap('gpt-4o'))).toBe(
'unsupported'
)
})
// Not the same as "cannot think": we have no rules for the provider, so the flow's own
// token is typed rather than picked.
it('asks for a typed token where it has no rules for the provider', () => {
expect(
reasoningControlState(
asReasoning({ provider: 'customai', model: 'deepseek-r1' }),
getReasoningCapability('customai', 'deepseek-r1')
)
).toBe('unknown')
})
// The session chat has no typed effort: a provider with no rules reads as unable to think.
it('offers no typed token to a chat that does not take one', () => {
expect(
reasoningControlState(
asReasoning({ provider: 'customai', model: 'deepseek-r1', typedWhenUnknown: false }),
getReasoningCapability('customai', 'deepseek-r1')
)
).toBe('unsupported')
})
// A provider with a full ladder must not be described as unreadable just because no
// model has been picked yet — which is the state right after choosing a resource.
it('waits for a model rather than blaming the provider', () => {
expect(
reasoningControlState(asReasoning({ model: undefined }), { supported: false, known: false })
).toBe('awaiting-model')
})
it('shows what the flow fixed when this chat cannot write it', () => {
expect(reasoningControlState(asReasoning({ writable: false }), cap('gpt-5.1'))).toBe('fixed')
})
})
describe('fixedReasoningReason', () => {
it('names the level the run will use', () => {
expect(
fixedReasoningReason(asReasoning({ value: 'high' }), { supported: true, known: true })
).toBe('high · set in the flow')
})
// The step naming no effort at all is the common shape; saying it was "set in the flow"
// would describe a line the flow does not contain.
it('does not claim a level the step never set', () => {
expect(
fixedReasoningReason(asReasoning({ value: undefined }), { supported: true, known: true })
).toBe('Not set in the flow, so the provider decides')
})
it('surfaces a level fixed on a model that cannot use it', () => {
expect(
fixedReasoningReason(asReasoning({ value: 'high', model: 'gpt-4o' }), {
supported: false,
known: true
})
).toContain('cannot think')
})
})
@@ -0,0 +1,219 @@
import type { AIProvider } from '$lib/gen'
import type { Item } from '$lib/utils'
import { REASONING_OFF } from './reasoningRegistry'
/**
* The contract between a chat and its model button.
*
* One component renders this menu for every chat — the copilot's own session chat and
* the flow chat — so the component knows only about rows, choices and a reasoning
* ladder. What a row means (a workspace AI resource, a prompt to edit, a reading
* preference) is the caller's business, and each caller derives its own config: a fixed
* one for the session chat, one derived from the flow's exposed inputs for flow chat.
*/
export type ModelChoice = {
/** Stable across rebuilds of the config; used as the `{#each}` key. */
key: string
label: string
/** Muted trailing text, e.g. the provider a resource speaks. */
hint?: string
selected: boolean
onSelect: () => void
}
export type ChoiceSection = {
/** Section heading, e.g. 'Provider' or 'Model'. */
label: string
options: ModelChoice[]
/** Fetched lists render one consistent loading line instead of the options. */
loading?: boolean
/** Shown when the list is empty and settled. */
emptyMessage?: string
maxHeight?: string
/**
* A typed entry under the options, for a value the list does not hold: an endpoint with no
* listing, or a model newer than the catalogue. An empty entry commits nothing.
*/
custom?: { placeholder: string; onCommit: (value: string) => void }
}
export type ChatModelSettingsConfig = {
/** The trigger's main text: the chosen model, or an invitation to choose one. */
label: string
title?: string
/** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */
badge?: { text: string; warn?: boolean }
/** Nothing here is editable — the trigger still names the model, but no menu opens. */
readOnly?: boolean
readOnlyReason?: string
/**
* Rows above and below the choice sections. Given the menu's own `close` because a
* row that opens a modal must close the menu first, while a row that toggles a
* preference must not.
*/
topItems?: (close: () => void) => Item[]
sections?: ChoiceSection[]
bottomItems?: (close: () => void) => Item[]
/**
* The thinking slider. The ladder is derived from the provider and model here rather
* than by each caller, so a new provider's effort levels reach every chat at once.
* `value` is the raw stored effort (undefined meaning the model's default), and
* `offToken` the token this caller stores for "off" — the copilot keeps its own
* sentinel and translates when it calls the provider, an agent writes the
* provider-native token straight into its step.
*/
reasoning?: ChatModelSettingsReasoning
}
export type ChatModelSettingsReasoning = {
/** Absent until the chat knows what it will run; the ladder then has nothing to stand on. */
provider: AIProvider | undefined
model: string | undefined
value: string | undefined
offToken: string | undefined
/**
* What an unset value means on the wire. The copilot fills one in before calling the
* provider, so unset really runs at the default effort and the button says so. An agent
* step omits the field entirely, so unset means whatever the provider does by itself —
* naming a level there would state something the run does not do.
*/
sendsDefaultWhenUnset: boolean
/**
* Whether this chat can write the effort back. False where the flow fixes it in the step:
* the run still uses it, so the button shows it and refuses to pretend otherwise — only
* the flow editor can change it.
*/
writable: boolean
/**
* Whether a provider the registry has no rules for gets a typed effort field. True for an
* agent, which writes the token straight into its step. False for the copilot, which then
* shows the model as unable to think.
*/
typedWhenUnknown: boolean
onSelect: (token: string) => void
}
/** What an unset agent effort reads as: the provider decides, and we do not know what. */
export const REASONING_PROVIDER_DEFAULT = 'default'
/**
* The effort to keep when the model changes, or nothing where the new model has no such
* level. Dropped rather than carried because a model that cannot think at that level either
* rejects the request or quietly runs at another one, and the button would name a level the
* run never used. Off survives only onto a model that can truly disable.
*
* A model the registry has no rules for drops it too, for the same reason: a chat without
* `typedWhenUnknown` draws no thinking control there, so a carried level would be invisible
* and unclearable while still going out on the wire — `resolveEffectiveReasoning` sends an explicitly set effort
* whatever the model, and a provider that rejects the field would then fail every turn with
* nothing on screen to explain it.
*/
export function carriedReasoning(
current: string | undefined,
offToken: string | undefined,
capability: { levels: string[]; canDisable: boolean }
): string | undefined {
if (current === undefined || current === '') return undefined
if (offToken !== undefined && current === offToken) {
return capability.canDisable ? current : undefined
}
return capability.levels.includes(current) ? current : undefined
}
/**
* What the menu shows for the reasoning ladder: the stops the slider offers, the one it
* sits on, and the suffix on the trigger.
*
* Pure and here rather than in the component because these three have to agree — a stop
* the slider renders as `off` must not read as the provider's own `none` on the button —
* and because the rules are provider-shaped enough to be worth testing directly.
*/
export function reasoningDisplay(
reasoning: ChatModelSettingsReasoning | undefined,
capability: { supported: boolean; levels: string[]; canDisable: boolean },
effective: string | undefined
): {
stops: string[]
currentStop: string
/** Trigger suffix, or undefined when there is nothing truthful to say. */
label: string | undefined
} {
if (!reasoning) return { stops: [], currentStop: '', label: undefined }
if (!capability.supported) {
// No ladder to place it on, but an effort that is explicitly set still goes out —
// `resolveEffectiveReasoning` sends one whatever the model — so the button names it.
// Saying nothing would hide from the reader what the run is about to do.
const set = reasoning.value ? reasoning.value : undefined
return { stops: [], currentStop: '', label: set }
}
// An off position only where the model can truly disable, else the provider would
// coerce it to the lowest level; then the provider-native levels.
const offToken = capability.canDisable ? reasoning.offToken : undefined
const stops = [...(offToken !== undefined ? [offToken] : []), ...capability.levels]
// An agent whose model disables by omission stores the empty string, which the run
// treats as no effort at all — so an unset value already sits on that stop.
const isOff = offToken !== undefined && (reasoning.value ?? '') === offToken
if (isOff) {
// The off token is provider-native and can read as anything ('none', 'disabled');
// on the button and on the slider it always reads as off.
return { stops, currentStop: offToken as string, label: REASONING_OFF }
}
if (reasoning.value === undefined || reasoning.value === '') {
// Where the provider takes an explicit disable, unset is a third state — the
// provider's own level, above off — that the ladder has no position for. The
// button still names it, and every stop the ladder does offer stays reachable.
return reasoning.sendsDefaultWhenUnset
? { stops, currentStop: effective ?? '', label: effective ?? REASONING_OFF }
: { stops, currentStop: '', label: REASONING_PROVIDER_DEFAULT }
}
return { stops, currentStop: reasoning.value, label: reasoning.value }
}
/** Which thinking control a chat should draw. */
export type ReasoningControlState =
/** The flow sets the effort itself; show what the run will use. */
| 'fixed'
/** No model chosen yet, so nothing can be said about its levels. */
| 'awaiting-model'
/** No rules for this provider, and the chat takes a typed token. */
| 'unknown'
/** Known levels — the ladder. */
| 'ladder'
/** Known to have none. */
| 'unsupported'
/**
* The control is always drawn; only its state varies. Decided here rather than in the markup
* so the states sit in one readable, testable place.
*/
export function reasoningControlState(
reasoning: ChatModelSettingsReasoning | undefined,
capability: { supported: boolean; known: boolean }
): ReasoningControlState {
if (!reasoning || !reasoning.writable) return 'fixed'
if (!reasoning.model) return 'awaiting-model'
if (!capability.known && reasoning.typedWhenUnknown) return 'unknown'
return capability.supported ? 'ladder' : 'unsupported'
}
/**
* What the row says when the chat cannot write the effort. Naming the level is the point: a
* button that names the model a run will use should name its thinking too, and a level fixed
* on a model that cannot use one is a broken flow worth seeing rather than a silent row.
*/
export function fixedReasoningReason(
reasoning: ChatModelSettingsReasoning | undefined,
capability: { supported: boolean; known: boolean }
): string {
const cannotThink = capability.known && !capability.supported
const model = reasoning?.model ?? 'this model'
if (!reasoning?.value) {
// The step names no effort, so the provider decides — saying it was "set in the flow"
// would describe a line the flow does not contain.
return cannotThink ? `${model} cannot think` : 'Not set in the flow, so the provider decides'
}
return cannotThink
? `${reasoning.value} · set in the flow, but ${model} cannot think`
: `${reasoning.value} · set in the flow`
}
@@ -77,9 +77,9 @@ describe('supportsReasoning (static registry)', () => {
}
// Bedrock translates the same sentinel on its Converse path, but only for
// Opus 5 — AWS documents Bedrock's Sonnet 5 as always thinking.
expect(
getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable
).toBe(true)
expect(getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable).toBe(
true
)
expect(
resolveRequestReasoning({
provider: 'aws_bedrock',
@@ -189,13 +189,29 @@ describe('supportsReasoning (static registry)', () => {
expect(supportsReasoning('mistral', 'mistral-medium-3.5')).toBe(true)
expect(getReasoningCapability('mistral', 'mistral-medium-3-5').canDisable).toBe(true)
})
it('returns no levels for providers without a registry entry', () => {
it('returns no levels for a model its provider family has no entry for', () => {
// The family is known, so the `false` is an answer: codestral does not reason.
expect(getReasoningCapability('mistral', 'codestral-latest')).toEqual({
supported: false,
levels: [],
canDisable: false
canDisable: false,
known: true
})
})
// `customai` fronts any OpenAI-compatible endpoint, so `supported: false` there is an
// absence of rules rather than a fact about the model. A caller that shows the reader
// "this model cannot think" has to tell the two apart.
it('admits when it has no rules for the provider at all', () => {
expect(getReasoningCapability('customai', 'deepseek-r1')).toEqual({
supported: false,
levels: [],
canDisable: false,
known: false
})
expect(getReasoningCapability('openai', 'gpt-4o').known).toBe(true)
expect(getReasoningCapability('anthropic', 'claude-sonnet-5').known).toBe(true)
})
it('only offers off where the model can truly disable thinking', () => {
// Gemini Pro enforces a thinking floor — no off option.
expect(getReasoningCapability('googleai', 'gemini-2.5-pro').canDisable).toBe(false)
@@ -231,14 +231,35 @@ export type ReasoningCapability = {
* level, making the switch a lie.
*/
canDisable: boolean
/**
* Whether `supported` is an answer or an absence of one. The registry has rules per
* provider family and falls through to `false` for the rest — `customai` above all,
* which fronts any OpenAI-compatible endpoint and may well serve a thinking model. A
* caller that presents `supported: false` as a fact must check this first, or it tells
* the reader a model cannot think when all we know is that we have never heard of it.
*/
known: boolean
}
/** Provider families the registry has real rules for; everything else is a shrug. */
const KNOWN_REASONING_FAMILIES: ReadonlySet<string> = new Set([
'anthropic',
'aws_bedrock',
'openai',
'azure_openai',
'openrouter',
'googleai',
'deepseek',
'mistral'
])
/** Resolve the reasoning capability of a model from the static registry. */
export function getReasoningCapability(provider: AIProvider, model: string): ReasoningCapability {
const bareModel = stripLegacyThinkingSuffix(model)
const known = KNOWN_REASONING_FAMILIES.has(reasoningProviderFamily(provider, bareModel))
const supported = supportsReasoningStatic(provider, bareModel)
if (!supported) {
return { supported: false, levels: [], canDisable: false }
return { supported: false, levels: [], canDisable: false, known }
}
const family = reasoningProviderFamily(provider, bareModel)
const levels =
@@ -251,7 +272,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea
: family === 'openrouter'
? openrouterReasoningLevels(bareModel)
: (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high'])
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel) }
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel), known }
}
/**
@@ -362,15 +383,11 @@ export function explicitOffToken(provider: AIProvider, model: string): Reasoning
// real off there and stays the wire form. Only the 5 family, which
// thinks when the field is absent, needs the explicit disable —
// Fable and Mythos reject it outright and get no off token at all.
return /claude-(opus|sonnet)-5/.test(model.toLowerCase())
? ANTHROPIC_OFF_SENTINEL
: undefined
return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) ? ANTHROPIC_OFF_SENTINEL : undefined
case 'aws_bedrock':
// Bedrock's Sonnet 5 cannot be disabled at all, so only Opus 5 gets
// the sentinel; the rest keep omission.
return model.toLowerCase().includes('claude-opus-5')
? ANTHROPIC_OFF_SENTINEL
: undefined
return model.toLowerCase().includes('claude-opus-5') ? ANTHROPIC_OFF_SENTINEL : undefined
case 'googleai':
// Gemini 2.5/3 think by default (dynamic budget / level). The backend
// proxy maps 'none' to off on Flash, or the floor on Pro (only
@@ -851,6 +851,7 @@
path={$pathStore}
hideSidebar={true}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
</div>
@@ -6,6 +6,7 @@
import FlowChatInterface from './FlowChatInterface.svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import type { FlowModule } from '$lib/gen'
interface Props {
/**
@@ -22,6 +23,8 @@
path: string
hideSidebar?: boolean
inputSchema?: Record<string, any>
/** The flow's modules, read for the provider wiring of its AI agent steps. */
flowModules?: FlowModule[]
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
@@ -33,6 +36,7 @@
path,
hideSidebar = false,
inputSchema = undefined,
flowModules = undefined,
description = undefined,
wideLayout = false
}: Props = $props()
@@ -102,6 +106,7 @@
{chat}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{workspace}
{description}
@@ -10,11 +10,23 @@
import { emptyString, type DynamicInput } from '$lib/utils'
import { onDestroy, tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import type { FlowModule } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
import {
agentModelGap,
composerOwnedInputs,
resolveAgentModelWiring,
showsModelButton,
withoutRejectedEffort
} from './agentChatInputs'
interface Props {
chat: Chat
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
/** The flow's modules, read for the provider wiring of its AI agent steps. */
flowModules?: FlowModule[]
path: string
workspace?: string
/** The flow's description, shown under the empty transcript's prompt. */
@@ -26,6 +38,7 @@
chat,
deploymentInProgress = false,
additionalInputsSchema,
flowModules,
path,
workspace = undefined,
description = undefined,
@@ -42,14 +55,45 @@
return undefined
})
// The model gets its own button, shaped like the copilot's model settings, driven by
// whichever provider fields the flow exposes. Every other flow input is asked for in
// the Configure-inputs modal.
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
// chat says what to go and do instead of offering controls that write nowhere.
const modelGap = $derived(agentModelGap(modelWiring))
const showModelButton = $derived(showsModelButton(modelWiring))
// LocalStorage helpers
const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
// State for additional inputs modal
let showInputsModal = $state(false)
let additionalInputsValues = $state<Record<string, any> | undefined>(
loadInputsFromStorage() ?? undefined
)
// Conversation settings, persisted per flow: what the reader chose, and nothing else.
let inputValues = $state<Record<string, any>>(loadInputsFromStorage() ?? {})
let modalDraft = $state<Record<string, any>>({})
/** What the flow's own form would open on. */
function schemaDefaults(schema: Record<string, any> | undefined): Record<string, any> {
const properties: Record<string, any> = schema?.properties ?? {}
return Object.fromEntries(
Object.entries(properties)
.filter(([, property]) => property?.default !== undefined)
.map(([name, property]) => [name, property.default])
)
}
// Derived rather than seeded into `inputValues`: the schema arrives with the flow, which
// on the deployed page is after this mounts, and only what the reader actually chose
// belongs in storage. A stored value wins over the default, including a deliberate empty.
const effectiveInputs = $derived({
...schemaDefaults(additionalInputsSchema),
...inputValues
})
// What the run actually gets. The composer's own controls keep themselves consistent as
// they are used; this is where a pair that was never chosen through them — a stored
// value, an author's default — is made safe before it reaches the provider.
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${path}`
@@ -73,40 +117,72 @@
}
}
function setInputValue(name: string, value: any) {
inputValues = { ...inputValues, [name]: value }
saveInputsToStorage(inputValues)
}
function handleModalConfirm() {
saveInputsToStorage(additionalInputsValues ?? {})
// The modal opens on `effectiveInputs`, so its draft carries a value for every
// defaulted input whether or not the reader touched one. Storing those would pin
// today's defaults for good — `effectiveInputs` gives a stored value precedence, so
// a later change to the flow's schema would never reach this reader again.
const defaults = schemaDefaults(additionalInputsSchema)
const kept = Object.fromEntries(
Object.entries({ ...inputValues, ...modalDraft }).filter(
([name, value]) => !deepEqual(value, defaults[name])
)
)
inputValues = kept
saveInputsToStorage(inputValues)
showInputsModal = false
}
function openInputsModal() {
const stored = loadInputsFromStorage()
if (stored) additionalInputsValues = stored
modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) }
showInputsModal = true
}
const hasMissingRequired = $derived.by(() => {
if (!additionalInputsSchema?.required?.length) return false
const values = additionalInputsValues ?? {}
return additionalInputsSchema.required.some(
(field: string) =>
values[field] === undefined || values[field] === '' || values[field] === null
)
})
// The host follows the chat it was built on for the life of this component: FlowChat
// remounts the interface under `{#key chat}`, so a later value of the prop never reaches it.
const chatHost = new FlowChatViewHost(
untrack(() => chat),
{
additionalInputs: () =>
additionalInputsSchema ? (loadInputsFromStorage() ?? additionalInputsValues) : undefined,
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
workspace: () => workspace,
sendDisabled: () => deploymentInProgress
sendDisabled: () => deploymentInProgress || !!modelGap
}
)
setChatViewHost(chatHost)
onDestroy(() => chatHost.dispose())
// What the Configure-inputs modal asks for: every flow input the composer does not
// edit itself.
const modalSchema = $derived.by(() => {
if (!additionalInputsSchema) return undefined
const promoted = new Set(composerOwnedInputs(modelWiring, undefined))
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
)
if (Object.keys(properties).length === 0) return undefined
const required: string[] = Array.isArray(additionalInputsSchema.required)
? additionalInputsSchema.required
: []
return {
...additionalInputsSchema,
properties,
required: required.filter((key) => !promoted.has(key))
}
})
const modalMissingRequired = $derived.by(() => {
if (!modalSchema?.required?.length) return false
return modalSchema.required.some((field: string) => {
const value = effectiveInputs[field]
return value === undefined || value === '' || value === null
})
})
// Older pages load when the reader reaches the top; the viewport stays where it was.
let scrollElement = $state<HTMLDivElement | undefined>(undefined)
let loadingOlder = false
@@ -126,12 +202,11 @@
}
</script>
<!-- Additional Inputs Modal -->
{#if additionalInputsSchema}
{#if modalSchema}
<Modal title="Configure inputs" bind:open={showInputsModal}>
<SchemaForm
schema={additionalInputsSchema}
bind:args={additionalInputsValues}
schema={modalSchema}
bind:args={modalDraft}
helperScript={dynamicInputHelperScript}
{workspace}
/>
@@ -159,7 +234,7 @@
{/snippet}
{#snippet footerSettings()}
{#if additionalInputsSchema}
{#if modalSchema}
<div class="relative">
<Button
unifiedSize="2xs"
@@ -171,11 +246,21 @@
>
Inputs
</Button>
{#if hasMissingRequired}
{#if modalMissingRequired}
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
</div>
{/if}
{#if modelWiring && showModelButton}
<!-- `runInputs`, not `effectiveInputs`: a stored effort the model rejects is dropped
before the run, and the button must not name one the run will not send. -->
<FlowChatModelSettings
wiring={modelWiring}
values={runInputs}
setValue={setInputValue}
{workspace}
/>
{/if}
{/snippet}
<!-- The transcript scroller fills its flex row, which needs a height to resolve
@@ -200,10 +285,10 @@
hideModeSelector
{wideLayout}
{emptyHint}
footerSettings={additionalInputsSchema ? footerSettings : undefined}
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress}
disabledMessage={deploymentInProgress ? 'Deployment in progress' : ''}
disabled={deploymentInProgress || !!modelGap}
disabledMessage={deploymentInProgress ? 'Deployment in progress' : (modelGap ?? '')}
loadPastChat={() => {}}
deletePastChat={() => {}}
saveAndClear={() => {}}
@@ -0,0 +1,299 @@
<script lang="ts">
/**
* The flow chat's model button: the same ChatModelSettings the session chat renders,
* over whatever the flow exposes.
*
* The agent takes one `provider` object, but an author can expose it field by field —
* fixing the resource in the step and letting the chat pick only the model, say. Each
* control here appears exactly when the flow wired the field behind it, so a chat never
* offers a knob whose value it could not write back.
*/
import ChatModelSettings from '$lib/components/copilot/ChatModelSettings.svelte'
import {
carriedReasoning,
type ChatModelSettingsConfig
} from '$lib/components/copilot/chatModelSettings'
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
import {
explicitOffToken,
getReasoningCapability
} from '$lib/components/copilot/reasoningRegistry'
import { ResourceService, type AIProvider } from '$lib/gen'
import type { Item } from '$lib/utils'
import { Plug, Plus } from 'lucide-svelte'
import { resource } from 'runed'
import {
composerDrivenFields,
type AgentModelWiring,
type ProviderField
} from './agentChatInputs'
interface Props {
wiring: AgentModelWiring
/** Every flow input value the composer holds for this conversation. */
values: Record<string, any>
setValue: (name: string, value: any) => void
workspace?: string
}
let { wiring, values, setValue, workspace }: Props = $props()
function fieldValue(field: ProviderField): any {
if (wiring.whole) return values[wiring.whole]?.[field]
const name = wiring.fields[field]
return name ? values[name] : wiring.fixed[field]
}
const driven = $derived(composerDrivenFields(wiring))
function editable(field: ProviderField): boolean {
return driven.has(field)
}
/** Written together, because choosing a resource also invalidates the model. */
function setFields(patch: Partial<Record<ProviderField, any>>) {
if (wiring.whole) {
setValue(wiring.whole, { ...(values[wiring.whole] ?? {}), ...patch })
return
}
for (const [field, value] of Object.entries(patch)) {
const name = wiring.fields[field as ProviderField]
if (name) setValue(name, value)
}
}
const resourceEditable = $derived(editable('resource'))
const modelEditable = $derived(editable('model'))
const effortEditable = $derived(editable('reasoning_effort'))
// Wired, but left to the Configure-inputs modal: the button has no model to place it on.
const effortInModal = $derived(wiring.fields.reasoning_effort !== undefined && !effortEditable)
// Nothing to write: the flow fixes the lot, so the button names it and opens nothing.
const readOnly = $derived(!resourceEditable && !modelEditable && !effortEditable)
const AI_RESOURCE_TYPES = Object.keys(AI_PROVIDERS)
// `$res:` is the stored form; the picker works in bare paths.
const resourcePath = $derived(
typeof fieldValue('resource') === 'string'
? fieldValue('resource').replace(/^\$res:/, '') || undefined
: undefined
)
const model = $derived(fieldValue('model'))
const effort = $derived(fieldValue('reasoning_effort'))
let appConnect: AppConnect | undefined = $state(undefined)
// Bumped after the connect drawer creates one, to re-list.
let resourcesVersion = $state(0)
// Set when a resource is created here: it can only be selected once the re-listing
// that follows tells us which provider it speaks.
let pendingResourcePath = $state<string | undefined>(undefined)
// A flow that fixes `kind` but exposes `resource` accepts resources of that kind only:
// `setFields` drops a `kind` it cannot write, so any other provider's resource would be
// listed, selected, and then run against the kind the flow still fixes.
const allowedResourceTypes = $derived.by(() => {
const fixedKind = editable('kind') ? undefined : (fieldValue('kind') as string | undefined)
return fixedKind && AI_RESOURCE_TYPES.includes(fixedKind) ? [fixedKind] : AI_RESOURCE_TYPES
})
const resources = resource(
() =>
resourceEditable ? { workspace, version: resourcesVersion, allowedResourceTypes } : undefined,
async (args) => {
const ws = args?.workspace
if (!ws) return []
const rows = await ResourceService.listResource({
workspace: ws,
resourceType: (args?.allowedResourceTypes ?? AI_RESOURCE_TYPES).join(',')
})
return rows.map((r) => ({
path: r.path,
// The row's own type is the provider; an unrecognised one is a custom endpoint.
provider: (AI_RESOURCE_TYPES.includes(r.resource_type ?? '')
? r.resource_type
: 'customai') as AIProvider
}))
}
)
const provider = $derived(
resources.current?.find((r) => r.path === resourcePath)?.provider ??
(fieldValue('kind') as AIProvider | undefined)
)
// Models the resource actually serves, asked of the provider. Its own catalogue is the
// fallback, so a listing that fails or is unsupported still offers real ids rather than
// an empty menu.
const models = resource(
() => ({ workspace, resourcePath, provider, modelEditable }),
async ({ workspace, resourcePath, provider, modelEditable }, _prev, { onCleanup }) => {
if (!modelEditable || !provider) return []
const fallback = AI_PROVIDERS[provider]?.defaultModels ?? []
if (!workspace || !resourcePath) return fallback
const controller = new AbortController()
onCleanup(() => controller.abort())
try {
const listed = await fetchAvailableModels(
resourcePath,
workspace,
provider,
controller.signal
)
return listed.length > 0 ? listed : fallback
} catch {
return fallback
}
}
)
$effect(() => {
if (!pendingResourcePath) return
const created = resources.current?.find((r) => r.path === pendingResourcePath)
if (created) {
pendingResourcePath = undefined
selectResource(created.path, created.provider)
}
})
/**
* The effort to write alongside a new model: `''` — the agent's "no effort" — wherever
* that model has no such level, so the composer never leaves behind a level the provider
* would reject. Writes nothing where the registry cannot speak for the model, since
* clearing a value on a guess would destroy the author's own default.
*/
function effortPatch(nextModel: string | undefined): Partial<Record<ProviderField, any>> {
if (!provider || !nextModel) return {}
const capability = getReasoningCapability(provider, nextModel)
if (!capability.known) return {}
const carried = carriedReasoning(
typeof effort === 'string' ? effort : undefined,
explicitOffToken(provider, nextModel) ?? '',
capability
)
return { reasoning_effort: carried ?? '' }
}
function selectResource(path: string, picked: AIProvider) {
setFields({
kind: picked,
resource: `$res:${path}`,
// The models of one provider mean nothing to another, and the new list only
// arrives async, so there is nothing to carry the current one against — nor the
// effort, which only means something against a model. Cleared only where the
// registry can speak for the new provider, for the same reason as `effortPatch`.
// `''`, not `undefined`: storage drops an undefined key, and the schema's default
// model would then come back under the new provider on reload.
model: '',
...(getReasoningCapability(picked, '').known ? { reasoning_effort: '' } : {})
})
}
function providerItem(close: () => void): Item {
const rows: Item[] = resources.loading
? [{ displayName: 'Loading resources...', disabled: true }]
: (resources.current ?? []).length === 0
? [{ displayName: 'No AI resource in this workspace', disabled: true }]
: (resources.current ?? []).map((r) => ({
displayName: r.path,
selected: r.path === resourcePath,
action: () => selectResource(r.path, r.provider)
}))
return {
displayName: 'Provider',
icon: Plug,
extra: providerSummary,
submenuItems: [
...rows,
{
// The same reach the form's ResourcePicker gives: create one without
// leaving for workspace settings first.
displayName: 'Add a resource',
icon: Plus,
separatorTop: true,
action: () => {
close()
appConnect?.open()
}
}
]
}
}
const config = $derived<ChatModelSettingsConfig>({
label: typeof model === 'string' && model ? model : 'Select a model',
title: 'Model & reasoning settings',
readOnly,
readOnlyReason: 'Set in the flow',
topItems: resourceEditable ? (close) => [providerItem(close)] : undefined,
sections: modelEditable
? [
{
label: 'Model',
options: (models.current ?? []).map((m) => ({
key: m,
label: m,
selected: m === model,
onSelect: () => setFields({ model: m, ...effortPatch(m) })
})),
loading: models.loading,
emptyMessage: provider ? 'No model listed' : 'Pick a provider first',
// The step's own provider picker takes any model id, and the modal does not
// ask for this input: without a typed entry, an endpoint that lists nothing
// leaves the run with no model.
custom: provider
? {
placeholder: 'Custom model id',
onCommit: (m) => setFields({ model: m, ...effortPatch(m) })
}
: undefined
}
]
: undefined,
// Present whatever we can say about it, since the run uses an effort either way: a ladder,
// a typed token, why there is none, or what the flow fixed. Absent only when the modal
// is the effort's editor.
reasoning: effortInModal
? undefined
: {
provider,
model: typeof model === 'string' && model ? model : undefined,
value: typeof effort === 'string' ? effort : undefined,
// An agent writes the provider-native token straight into its step, so there is no
// sentinel to translate later. Where a model disables by omission instead, the empty
// string is that off: the run reads an empty `reasoning_effort` as absent
// (types.rs `get_reasoning_effort`).
offToken:
provider && typeof model === 'string' && model
? (explicitOffToken(provider, model) ?? '')
: '',
// An agent step omits `reasoning_effort` when it is unset, so the provider picks —
// naming a level would claim something the run does not do.
sendsDefaultWhenUnset: false,
writable: effortEditable,
typedWhenUnknown: true,
onSelect: (token) => setFields({ reasoning_effort: token })
}
})
</script>
{#snippet providerSummary()}
{#if resourcePath}
<span class="shrink-0 text-tertiary truncate max-w-[80px]">{resourcePath}</span>
{/if}
{/snippet}
{#if resourceEditable}
<AppConnect
bind:this={appConnect}
{workspace}
on:refresh={(e) => {
resourcesVersion++
if (e.detail) {
pendingResourcePath = e.detail
}
}}
/>
{/if}
<ChatModelSettings {config} />
@@ -0,0 +1,492 @@
import { describe, expect, it } from 'vitest'
import {
agentModelGap,
agentModelWiringInputs,
composerOwnedInputs,
parseProviderTransform,
resolveAgentModelWiring,
showsModelButton,
withoutRejectedEffort
} from './agentChatInputs'
import type { FlowModule } from '$lib/gen'
function agent(expr: string): FlowModule {
return {
id: 'a',
value: {
type: 'aiagent',
tools: [],
input_transforms: { provider: { type: 'javascript', expr } }
}
} as unknown as FlowModule
}
describe('parseProviderTransform', () => {
it('reads a whole-object reference', () => {
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.model' })).toEqual({
whole: 'model',
fields: {},
fixed: {}
})
})
it('splits a partial expression into wired and fixed fields', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model, reasoning_effort: flow_input.thinking }"
})
expect(wiring).toEqual({
fields: { model: 'model', reasoning_effort: 'thinking' },
fixed: { kind: 'anthropic', resource: '$res:u/admin/claude' }
})
})
// The regression this detector exists for: counting flow_input references would read
// this as one input carrying the whole provider object, and the composer would write
// {kind, resource, model} into an input the expression uses as the model name.
it('does not mistake a single-reference partial expression for a whole-object one', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }"
})
expect(wiring?.whole).toBeUndefined()
expect(wiring?.fields).toEqual({ model: 'model' })
})
// The flow editor's JS field commonly holds a parenthesised object, which is how an
// author writes one without it reading as a block.
it('accepts a parenthesised object expression', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: `({
"kind": "anthropic",
"resource": "$res:u/admin/anthropic_windmill_codegen",
"model": "claude-sonnet-5",
"reasoning_effort": flow_input.thinking
})`
})
expect(wiring).toEqual({
fields: { reasoning_effort: 'thinking' },
fixed: {
kind: 'anthropic',
resource: '$res:u/admin/anthropic_windmill_codegen',
model: 'claude-sonnet-5'
}
})
})
it('treats a static provider as entirely fixed', () => {
expect(
parseProviderTransform({
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
})
).toEqual({
fields: {},
fixed: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
})
})
it.each([
['{ ...base, model: flow_input.model }', 'a spread could supply any field'],
['{ model: pickModel(flow_input.x) }', 'a call is not classifiable'],
['flow_input.model + 1', 'not a bare reference'],
['{ model: ', 'unparseable']
])('gives up on %s', (expr) => {
expect(parseProviderTransform({ type: 'javascript', expr } as any)).toBeUndefined()
})
})
describe('agentModelWiringInputs', () => {
// The button writes `kind` only alongside a resource, since a provider is picked as a
// pair. Hiding a kind input it cannot write would leave the run without one.
it('keeps a kind input the model button cannot write', () => {
const wiring = resolveAgentModelWiring([
agent(`({ kind: flow_input.k, "resource": "$res:u/admin/claude", model: flow_input.m })`)
])
expect(agentModelWiringInputs(wiring)).toEqual(['m'])
})
it('hides a kind input it writes with the resource', () => {
const wiring = resolveAgentModelWiring([
agent(`({ kind: flow_input.k, resource: flow_input.r, model: flow_input.m })`)
])
expect(agentModelWiringInputs(wiring)?.sort()).toEqual(['k', 'm', 'r'])
})
// A thinking control is usable whatever the registry knows about the model — a ladder, a
// typed token, or why there is neither — so a wired effort is the button's there.
it('claims a wired reasoning_effort whatever the provider', () => {
const custom = resolveAgentModelWiring([
agent(
`({ "kind": "customai", "resource": "$res:u/admin/custom", model: flow_input.m, reasoning_effort: flow_input.thinking })`
)
])
expect(agentModelWiringInputs(custom)?.sort()).toEqual(['m', 'thinking'])
const known = resolveAgentModelWiring([
agent(
`({ "kind": "openai", "resource": "$res:u/admin/oai", "model": "gpt-4o", reasoning_effort: flow_input.thinking })`
)
])
expect(agentModelWiringInputs(known)).toEqual(['thinking'])
})
// Agents on different fixed models leave the button no model to place the effort on, so
// it would say "Pick a model first" with nothing to pick. The modal keeps the input.
it('leaves a shared effort to the modal when the agents fix different models', () => {
const resource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
const wiring = resolveAgentModelWiring([
agent(`({ ${resource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`),
agent(`({ ${resource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`)
])
expect(wiring?.fields.reasoning_effort).toBe('thinking')
expect(agentModelWiringInputs(wiring)).toEqual([])
expect(showsModelButton(wiring)).toBe(false)
})
// The same one level up: agents on different providers leave the model menu nothing to
// list and no provider to gate a typed id, so the shared model input stays in the modal.
it('leaves a shared model to the modal when the agents fix different providers', () => {
const wiring = resolveAgentModelWiring([
agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.model })`),
agent(
`({ "kind": "azure_openai", "resource": "$res:u/admin/azure", model: flow_input.model })`
)
])
expect(wiring?.fields.model).toBe('model')
expect(agentModelWiringInputs(wiring)).toEqual([])
expect(showsModelButton(wiring)).toBe(false)
})
})
// The modal is whatever this does not return, so the two cannot disagree about an input.
describe('composerOwnedInputs', () => {
const wiring = () =>
resolveAgentModelWiring([
agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.m })`)
])
it('claims the model wiring and the attachments target together', () => {
expect(composerOwnedInputs(wiring(), { name: 'files' })?.sort()).toEqual(['files', 'm'])
})
it('claims the attachments input whatever the workspace can do with it', () => {
// No object storage is a state the paperclip shows, not a handover: the modal could
// not upload either, and the run would fail fetching a key typed there.
expect(composerOwnedInputs(undefined, { name: 'files' })).toEqual(['files'])
})
it('leaves an input no control fits to the modal', () => {
expect(composerOwnedInputs(wiring(), undefined)).toEqual(['m'])
})
})
describe('resolveAgentModelWiring', () => {
const fixedResource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
it('drives a field every agent reads from the same input', () => {
const wiring = resolveAgentModelWiring([
agent(
`({ ${fixedResource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`
),
agent(
`({ ${fixedResource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`
)
])
expect(wiring?.fields).toEqual({ reasoning_effort: 'thinking' })
// The agents run different models, so there is no single one to name.
expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/admin/claude' })
})
it('drops a field the agents disagree about', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, reasoning_effort: flow_input.thinking })`),
agent(`({ ${fixedResource}, reasoning_effort: flow_input.other })`)
])
expect(wiring?.fields.reasoning_effort).toBeUndefined()
})
// A nested agent is the parent agent's tool, not a step the reader is talking to: the
// graph walks it as a child module, and counting it here would defeat the chat's own
// model control (this is the shape of the all-tools example flow).
it("ignores an agent carried as another agent's tool", () => {
const parent = agent('flow_input.model')
;(parent.value as any).tools = [
{
id: 'summarize',
value: {
tool_type: 'flowmodule',
type: 'aiagent',
tools: [],
input_transforms: {
provider: { type: 'static', value: { kind: 'anthropic', model: 'claude-sonnet-5' } }
}
}
}
]
expect(resolveAgentModelWiring([parent])).toEqual({
whole: 'model',
fields: {},
fixed: {},
someAgentCannotRun: false
})
})
it('finds an agent inside a loop or a branch', () => {
const inner = agent(`({ ${fixedResource}, model: flow_input.model })`)
const loop = {
id: 'loop',
value: { type: 'forloopflow', modules: [inner] }
} as unknown as FlowModule
const branch = {
id: 'branch',
value: { type: 'branchone', default: [], branches: [{ modules: [inner] }] }
} as unknown as FlowModule
expect(resolveAgentModelWiring([loop])?.fields.model).toBe('model')
expect(resolveAgentModelWiring([branch])?.fields.model).toBe('model')
})
// The control writes one flow input; an agent that fixes the field instead never reads
// it, so offering the control would move one agent and leave the other where it was.
it('does not offer a field one agent wires and another fixes', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, model: flow_input.model })`),
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
])
expect(wiring?.fields.model).toBeUndefined()
expect(wiring?.fixed.model).toBeUndefined()
})
// Disagreeing about the model is not the same as having no model: the flow runs, on a
// different one per agent, and the composer has nothing to fix.
it('says nothing about a model the agents merely disagree about', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
])
expect(agentModelGap(wiring)).toBeUndefined()
})
it('still reports an agent with nothing to call', () => {
expect(
agentModelGap(resolveAgentModelWiring([agent(`({ "kind": "openai", "model": "" })`)]))
).toBe('Pick a provider and model on the AI agent step to use this chat.')
})
// An expression the parser cannot account for could supply anything, so the agents it
// belongs to cannot be spoken for either.
it("offers nothing when one agent's provider cannot be read", () => {
expect(
resolveAgentModelWiring([
agent(`({ ${fixedResource}, model: flow_input.model })`),
agent(`({ ...base, model: flow_input.model })`)
])
).toBeUndefined()
})
// Disagreement is not the same as absence, but an agent with an empty model still
// cannot run, however well its neighbour is configured.
it('keeps warning when one agent has no model and another does', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
agent(`({ ${fixedResource}, "model": "" })`)
])
expect(agentModelGap(wiring)).toBe(
'Pick a provider and model on the AI agent step to use this chat.'
)
})
it('refuses a flow mixing whole-object and field-by-field wiring', () => {
expect(
resolveAgentModelWiring([
agent('flow_input.provider'),
agent(`({ ${fixedResource}, model: flow_input.model })`)
])
).toBeUndefined()
})
})
describe('withoutRejectedEffort', () => {
const wiring = (fields: Record<string, string>, fixed: Record<string, any> = {}) =>
({ fields, fixed }) as any
// The live 400 this guards: Anthropic turns any effort into adaptive thinking, which
// Haiku rejects outright ("adaptive thinking is not supported on this model").
it('drops an effort the chosen model rejects', () => {
const values = { model: 'claude-haiku-4-5-20251001', reasoning_effort: 'high' }
expect(
withoutRejectedEffort(
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
values
)
).toEqual({ model: 'claude-haiku-4-5-20251001', reasoning_effort: '' })
})
it('keeps an effort the model takes', () => {
const values = { model: 'claude-sonnet-5', reasoning_effort: 'high' }
expect(
withoutRejectedEffort(
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
values
)
).toBe(values)
})
// Clearing on a guess would override the author's own default.
it('leaves the value alone for a family the registry cannot speak for', () => {
const values = { model: 'some-model', reasoning_effort: 'high' }
expect(
withoutRejectedEffort(
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'customai' }),
values
)
).toBe(values)
})
// A flow that wires `provider` as one object keeps the effort inside it, so reading
// `fields.reasoning_effort` finds nothing and the 400 would go out unchecked.
it('clears the effort inside a whole-object provider input', () => {
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
const values = {
provider: {
kind: 'anthropic',
model: 'claude-haiku-4-5-20251001',
reasoning_effort: 'high'
}
}
expect(withoutRejectedEffort(wiring, values)).toEqual({
provider: {
kind: 'anthropic',
model: 'claude-haiku-4-5-20251001',
reasoning_effort: ''
}
})
})
it('leaves a whole-object provider alone when the model takes the effort', () => {
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
const values = {
provider: { kind: 'anthropic', model: 'claude-sonnet-5', reasoning_effort: 'high' }
}
expect(withoutRejectedEffort(wiring, values)).toBe(values)
})
// A model that reasons can still refuse a particular token, and the provider answers with a
// 400 on every turn.
it('drops a level or off token a reasoning model does not take', () => {
const openai = wiring({ model: 'model', reasoning_effort: 'effort' }, { kind: 'openai' })
expect(withoutRejectedEffort(openai, { model: 'gpt-5', effort: 'none' }).effort).toBe('')
expect(withoutRejectedEffort(openai, { model: 'gpt-5.1', effort: 'xhigh' }).effort).toBe('')
const kept = { model: 'gpt-5.1', effort: 'none' }
expect(withoutRejectedEffort(openai, kept)).toBe(kept)
})
})
/**
* The shape agent chat is usually built in: one agent answers the reader, others do work of
* their own in branches. A sub-agent never sees the message, so what it runs on is not a
* setting this conversation has.
*/
describe('agents that do not read the message', () => {
const answerer = (provider: string) =>
({
id: 'answerer',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
provider: { type: 'javascript', expr: provider }
}
}
}) as unknown as FlowModule
const subAgent = (provider: string, message = "'critique: ' + results.answerer") =>
({
id: 'critic',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
user_message: { type: 'javascript', expr: message },
provider: { type: 'javascript', expr: provider }
}
}
}) as unknown as FlowModule
const wired = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: flow_input.model })`
const fixed = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: 'claude-sonnet-5' })`
it('keeps the model control when only a sub-agent fixes its own model', () => {
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed)])
expect(wiring?.fields.model).toBe('model')
})
// Two agents both answering the reader still have to agree: either might be the one
// that replies, so a control moving one of them would be a lie about the other.
it('still needs agreement among the agents that do read the message', () => {
const wiring = resolveAgentModelWiring([
answerer(wired),
subAgent(fixed, 'flow_input.user_message')
])
expect(wiring?.fields.model).toBeUndefined()
})
// Nothing to scope to means the flow is shaped in some way this cannot read, so every
// agent counts again rather than none.
it('falls back to every agent when none reads the message', () => {
const wiring = resolveAgentModelWiring([subAgent(wired), subAgent(fixed)])
expect(wiring?.fields.model).toBeUndefined()
})
// The editor writes the dot form, but an author may hand-edit either. A shape this does
// not recognise drops that agent out of the unanimity check it should be part of.
it.each([
["flow_input['user_message']", 'bracket access'],
['flow_input?.user_message', 'optional chaining'],
["'Answer politely: ' + flow_input.user_message", 'embedded in a prompt'],
['flow_input.user_message + flow_input.tone', 'read alongside another input'],
['flow_input.user_message // the message', 'a trailing comment'],
['flow_input.user_message\n// why', 'a comment on its own last line']
])('treats %s as reading the message', (message) => {
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed, message as string)])
expect(wiring?.fields.model).toBeUndefined()
})
it.each([
['// see flow_input.user_message', 'a mention in a comment'],
["'flow_input.user_message'", 'a mention in a string'],
['flow_input.user_message_extra', 'a different input with the same prefix']
])('does not treat %s as reading the message', (message) => {
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed, message as string)])
expect(wiring?.fields.model).toBe('model')
})
})
// An author annotating their own provider must not lose the model control for it: a
// comment beside the expression is not another expression.
describe('parseProviderTransform with comments', () => {
it.each([
['flow_input.provider // the one to use', 'a trailing line comment'],
['flow_input.provider /* the one to use */', 'a trailing block comment'],
['/* pick one */ flow_input.provider', 'a leading comment']
])('reads a whole-object reference despite %s', (expr) => {
expect(parseProviderTransform({ type: 'javascript', expr })?.whole).toBe('provider')
})
it('reads an object literal with a comment inside it', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: `({ kind: 'anthropic', /* fixed */ resource: '$res:u/admin/c', model: flow_input.model })`
})
expect(wiring?.fields.model).toBe('model')
})
// The check exists to reject an expression with something else beside it; a second
// expression is still something else.
it('still refuses a second expression beside it', () => {
expect(
parseProviderTransform({ type: 'javascript', expr: 'flow_input.provider, 1' })
).toBeUndefined()
})
})
@@ -0,0 +1,441 @@
import type { AIProvider, FlowModule, InputTransform } from '$lib/gen'
import { explicitOffToken, getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
import { carriedReasoning } from '$lib/components/copilot/chatModelSettings'
import { parseExpressionAt } from 'acorn'
/**
* The flow's own AI agent steps, including those inside loops and branches but never one
* carried as another agent's tool.
*
* The graph walks an agent's tools as if they were child steps (flowTree.ts), which is
* right for the graph and wrong here: a tool agent's provider belongs to the agent that
* calls it, not to the chat. Counting it would let a nested agent's fixed model defeat the
* composer's model control on the step the reader is actually talking to.
*/
function agentSteps(modules: FlowModule[] | undefined): FlowModule[] {
const found: FlowModule[] = []
const walk = (mods: FlowModule[]) => {
for (const module of mods) {
const value = module.value as any
if (value?.type === 'aiagent') {
found.push(module)
continue
}
if (value?.type === 'forloopflow' || value?.type === 'whileloopflow') {
walk(value.modules ?? [])
} else if (value?.type === 'branchone') {
walk(value.default ?? [])
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
} else if (value?.type === 'branchall') {
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
}
}
}
walk(modules ?? [])
return found
}
/** Block and line comments removed, so what is left is only what affects the value. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
}
/**
* An expression wrapped so acorn will read it: parenthesised, because a leading `{` would
* otherwise parse as a block, and on its own line, because a trailing `// comment` would
* otherwise swallow the closing paren and make the whole thing unparseable.
*/
function parenthesised(expr: string): string {
return `(\n${expr}\n)`
}
/** The flow input the server requires on a chat-enabled flow, and stores as the message. */
const MESSAGE_INPUT = 'user_message'
/**
* The agents the reader is talking to: the ones the chat's message is fed to.
*
* A flow commonly runs one agent on the message and others on work of their own — a
* critic reading `results.x`, a classifier in a branch. Those never see what was typed,
* so what they run on is not a setting this conversation has: letting one of them differ
* on the model would take the model control away from the agent that does answer.
*
* The message need not be the whole prompt — an author wraps it in context freely — so
* this asks whether the expression reads it at all. A flow where no agent reads it is one
* shaped in some way this cannot speak for, and every agent counts again rather than none.
*/
function chatFacingAgents(modules: FlowModule[] | undefined): FlowModule[] {
const agents = agentSteps(modules)
const facing = agents.filter((module) => {
const transform = (module.value as any).input_transforms?.[MESSAGE_INPUT]
return transform?.type === 'javascript' && readsFlowInput(transform.expr, MESSAGE_INPUT)
})
return facing.length > 0 ? facing : agents
}
/**
* Whether an expression reads `flow_input.<name>` anywhere in it.
*
* Parsed rather than matched: the author may write `flow_input['user_message']` as readily
* as the dot form the editor emits, and a mention inside a comment or a string is not a
* read. Reading two inputs is still a read of each, which is why this is not the question
* "which single input feeds a field" that the composer asks of a wired field.
*/
function readsFlowInput(expr: string, name: string): boolean {
let root: unknown
try {
root = parseExpressionAt(parenthesised(expr), 0, { ecmaVersion: 'latest' })
} catch {
return false
}
let found = false
const visit = (node: any) => {
if (found || !node || typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach(visit)
return
}
if (flowInputName(node) === name) {
found = true
return
}
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end') continue
visit(node[key])
}
}
visit(root)
return found
}
/** A provider value as the agent stores it. */
export type AgentModel = { kind?: string; model?: string; reasoning_effort?: string }
/** The provider fields the composer can read or drive. */
const PROVIDER_FIELDS = ['kind', 'resource', 'model', 'reasoning_effort'] as const
export type ProviderField = (typeof PROVIDER_FIELDS)[number]
/**
* How an AI agent step's `provider` is supplied, field by field.
*
* The agent takes one `provider` object, so an author who wants the chat to choose only
* the model writes the rest as literals around it:
*
* { kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }
*
* `fields` names the flow input behind each field the author exposed, `fixed` holds the
* literals, and `whole` covers the plain `flow_input.x` case where one input carries the
* entire object. Reading them apart is what lets the composer offer exactly the knobs the
* flow exposed — and stops a partial expression from being mistaken for a whole-object
* one, which would write a provider object into an input the flow reads as a model name.
*/
export type AgentModelWiring = {
whole?: string
fields: Partial<Record<ProviderField, string>>
fixed: Partial<Record<ProviderField, any>>
/**
* One of the agents names no resource or no model of its own and no flow input feeds
* it, so that agent's run fails whatever the others do. Held apart from the fields,
* which describe what the composer may offer.
*/
someAgentCannotRun?: boolean
}
/** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */
function flowInputName(node: any): string | undefined {
const member = node?.type === 'ChainExpression' ? node.expression : node
if (member?.type !== 'MemberExpression') return undefined
if (member.object?.type !== 'Identifier' || member.object.name !== 'flow_input') return undefined
if (!member.computed && member.property?.type === 'Identifier') return member.property.name
if (member.computed && member.property?.type === 'Literal') {
return typeof member.property.value === 'string' ? member.property.value : undefined
}
return undefined
}
/** A property's name, for the plain `key:` and `'key':` forms only. */
function propertyKey(property: any): string | undefined {
if (property?.type !== 'Property' || property.computed) return undefined
if (property.key?.type === 'Identifier') return property.key.name
if (property.key?.type === 'Literal' && typeof property.key.value === 'string') {
return property.key.value
}
return undefined
}
/**
* Read a `provider` input transform. Anything this cannot account for in full returns
* undefined rather than a guess: the composer then leaves the field alone instead of
* writing into an expression it does not understand.
*/
export function parseProviderTransform(
transform: InputTransform | undefined
): AgentModelWiring | undefined {
if (transform?.type === 'static') {
const value = transform.value
if (!value || typeof value !== 'object') return undefined
const fixed: AgentModelWiring['fixed'] = {}
for (const field of PROVIDER_FIELDS) {
if (value[field] !== undefined) fixed[field] = value[field]
}
return { fields: {}, fixed }
}
if (transform?.type !== 'javascript') return undefined
// The author's own text may already be wrapped, so any balanced surround is fine — what
// the span check rejects is an expression with something else beside it.
const source = parenthesised(transform.expr)
let node: any
try {
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
} catch {
return undefined
}
// A comment beside the expression is not another expression: the author annotating their
// own provider must not cost them the model control. Dropped before the check so what
// remains is only what would change the value — and so a paren inside a comment is not
// counted as one of the wrapping pair.
const before = withoutComments(source.slice(0, node.start))
const after = withoutComments(source.slice(node.end))
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
const whole = flowInputName(node)
if (whole) return { whole, fields: {}, fixed: {} }
if (node.type !== 'ObjectExpression') return undefined
const fields: AgentModelWiring['fields'] = {}
const fixed: AgentModelWiring['fixed'] = {}
for (const property of node.properties) {
const key = propertyKey(property)
// A spread or a computed key could supply any field, so nothing here is knowable.
if (!key) return undefined
if (!(PROVIDER_FIELDS as readonly string[]).includes(key)) continue
const name = flowInputName(property.value)
if (name) {
fields[key as ProviderField] = name
} else if (property.value?.type === 'Literal') {
fixed[key as ProviderField] = property.value.value
} else {
return undefined
}
}
return { fields, fixed }
}
/** How one agent supplies a provider field: from an input, as a literal, or not at all. */
type FieldSupply =
| { kind: 'wired'; name: string }
| { kind: 'fixed'; value: any }
| { kind: 'absent' }
/** Whether one agent supplies a field with nothing usable: no input, and no literal. */
function agentFieldEmpty(wiring: AgentModelWiring, field: ProviderField): boolean {
if (wiring.fields[field] !== undefined) return false
const value = wiring.fixed[field]
return value === undefined || value === ''
}
function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSupply {
const name = wiring.fields[field]
if (name !== undefined) return { kind: 'wired', name }
const value = wiring.fixed[field]
if (value !== undefined) return { kind: 'fixed', value }
return { kind: 'absent' }
}
/**
* The provider wiring the chat can act on, across every AI agent in the flow.
*
* With several agents a field is drivable when they agree on it: one flow input feeding
* it, or one literal fixing it. Where they disagree there is no single value to show or
* write, so that field is dropped and the others still work. A flow mixing whole-object
* and field-by-field wiring is ambiguous throughout and yields nothing.
*/
export function resolveAgentModelWiring(
modules: FlowModule[] | undefined
): AgentModelWiring | undefined {
const agents = chatFacingAgents(modules)
const parsed = agents.map((agent) =>
parseProviderTransform((agent.value as any).input_transforms?.['provider'])
)
if (parsed.length === 0) return undefined
// An agent whose provider cannot be read is an agent the composer cannot speak for:
// dropping it would let the rest declare a control that governs only some of them.
if (parsed.some((wiring) => wiring === undefined)) return undefined
const wirings = parsed as AgentModelWiring[]
// Whether any single agent has nothing to call, which stays true however the others
// are wired — the gap message is about that agent, not about their agreement.
const someAgentCannotRun = wirings.some(
(wiring) =>
!wiring.whole && (agentFieldEmpty(wiring, 'resource') || agentFieldEmpty(wiring, 'model'))
)
if (wirings.length === 1) return { ...wirings[0], someAgentCannotRun }
const wholes = new Set(wirings.map((w) => w.whole))
if (wholes.size === 1 && !wholes.has(undefined)) {
return { whole: [...wholes][0], fields: {}, fixed: {}, someAgentCannotRun }
}
if (wirings.some((w) => w.whole !== undefined)) return undefined
const fields: AgentModelWiring['fields'] = {}
const fixed: AgentModelWiring['fixed'] = {}
for (const field of PROVIDER_FIELDS) {
// Every agent has to supply the field the same way for the composer to speak for
// them all. One wired name among agents that otherwise fix it is not agreement:
// the control would move that one agent and leave the others where they are.
const supplies = new Set(wirings.map((w) => JSON.stringify(fieldSupply(w, field))))
// Disagreement leaves the field neither editable nor known: a control offered here
// would govern one agent while the rest ran on something else.
if (supplies.size > 1) continue
const supply: FieldSupply = JSON.parse([...supplies][0])
if (supply.kind === 'wired') fields[field] = supply.name
else if (supply.kind === 'fixed') fixed[field] = supply.value
}
return { fields, fixed, someAgentCannotRun }
}
/**
* Why the chat cannot run, when the agent's own provider is incomplete.
*
* A freshly added agent carries `{ kind: 'openai', model: '', resource: '' }`, so it names
* a provider kind while having nothing to call — the run fails and the chat can do nothing
* about it, because no flow input feeds either field. Saying so beats a dead model button.
* A field the flow exposes is never a gap: the reader picks it in the composer.
*/
export function agentModelGap(wiring: AgentModelWiring | undefined): string | undefined {
// No agent, several of them, or an expression we cannot read: not ours to judge.
if (!wiring || wiring.whole) return undefined
// Asked of each agent rather than of what they agree on: agents that merely disagree
// about the model all have one, and the message would be false — while an agent with
// an empty model still cannot run, however well the others are configured.
return wiring.someAgentCannotRun
? 'Pick a provider and model on the AI agent step to use this chat.'
: undefined
}
/**
* The provider fields the model button edits. Everything else wired to an input is left to
* the Configure-inputs modal, and the button draws no control for it.
*
* A field is the button's only where its control can be used, which each field makes depend
* on the one before it. An input promoted without a usable control is one nothing can edit.
* - `resource` is always usable: the submenu lists the workspace's AI resources.
* - `kind` is written only alongside a resource, since a provider is picked as a pair. Wired
* with the resource fixed, the button has nothing to write it with.
* - `model` needs a provider to list models for and to gate the typed entry: a wired resource
* or kind, or one fixed kind. Agents that fix different kinds leave none.
* - `reasoning_effort` needs a model to place the effort on: a driven model or one fixed
* model. Agents that fix different models leave none.
*/
export function composerDrivenFields(wiring: AgentModelWiring): Set<ProviderField> {
if (wiring.whole) return new Set(PROVIDER_FIELDS)
const wired = (field: ProviderField) => wiring.fields[field] !== undefined
const driven = new Set<ProviderField>()
if (wired('resource')) {
driven.add('resource')
if (wired('kind')) driven.add('kind')
}
const providerKnown = wired('resource') || wired('kind') || fixedOne(wiring, 'kind')
if (wired('model') && providerKnown) driven.add('model')
const modelKnown = driven.has('model') || fixedOne(wiring, 'model')
if (wired('reasoning_effort') && modelKnown) driven.add('reasoning_effort')
return driven
}
/** Whether every agent fixes the field to the same non-empty literal. */
function fixedOne(wiring: AgentModelWiring, field: ProviderField): boolean {
const value = wiring.fixed[field]
return value !== undefined && value !== ''
}
/** The flow inputs the model button writes, so the modal does not ask for them twice. */
export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] {
if (!wiring) return []
if (wiring.whole) return [wiring.whole]
return [...composerDrivenFields(wiring)]
.map((field) => wiring.fields[field])
.filter((name): name is string => !!name)
}
/**
* Whether the composer draws a model button at all: something to write, or a fixed model to
* name. Agents that fix different models leave neither.
*/
export function showsModelButton(wiring: AgentModelWiring | undefined): boolean {
if (!wiring) return false
return agentModelWiringInputs(wiring).length > 0 || fixedOne(wiring, 'model')
}
/**
* The flow inputs the composer edits, and therefore the ones the Configure-inputs modal must
* not ask for. The modal is whatever is left, so this is the single answer to "who edits
* this" rather than a list kept in step with the controls that render.
*
* Only the shape of the flow decides it. Whether a control can act *right now* — no rules
* for a provider's thinking levels, say — is a state that control shows, not a reason to
* hand the input to an editor that would be no more able. The attachments target is the
* composer's other owner: the input its paperclip uploads into, where the flow has one.
*/
export function composerOwnedInputs(
wiring: AgentModelWiring | undefined,
attachmentsTarget: { name: string } | undefined
): string[] {
return [...agentModelWiringInputs(wiring), ...(attachmentsTarget ? [attachmentsTarget.name] : [])]
}
/**
* The run's inputs with a reasoning effort the chosen model cannot take removed.
*
* The model button reconciles the two when the reader switches model, which covers the only
* way the composer can put them out of step. It is not the only way they get out of step:
* a value stored from an earlier visit, a default the flow author wrote, or a model chosen
* before the effort was, all arrive already mismatched — and the provider answers a
* mismatch with a 400 that names neither input ("adaptive thinking is not supported on this
* model"). Checked here, where the run's arguments are settled, so every route is covered.
*
* Only where the registry positively knows the model rejects it. An unknown family keeps
* whatever the author wrote: dropping a value on a guess would override their own default.
*/
export function withoutRejectedEffort(
wiring: AgentModelWiring | undefined,
values: Record<string, any>
): Record<string, any> {
if (!wiring) return values
// One input carrying the whole provider object: the three fields are read from it and
// the effort is cleared inside it, since that is where the agent will look for them.
if (wiring.whole) {
const provider = values[wiring.whole]
if (!provider || typeof provider !== 'object') return values
if (!rejectsEffort(provider.kind, provider.model, provider.reasoning_effort)) return values
return { ...values, [wiring.whole]: { ...provider, reasoning_effort: '' } }
}
const effortInput = wiring.fields.reasoning_effort
if (!effortInput) return values
const kindInput = wiring.fields.kind
const modelInput = wiring.fields.model
const rejected = rejectsEffort(
kindInput ? values[kindInput] : wiring.fixed.kind,
modelInput ? values[modelInput] : wiring.fixed.model,
values[effortInput]
)
return rejected ? { ...values, [effortInput]: '' } : values
}
/**
* Whether the registry positively says this model will not take this effort: no reasoning at
* all, a level it does not have (`xhigh` on `gpt-5.1`), or an off token it cannot honour
* (`none` on `gpt-5`). The same rule the button applies when the model changes.
*/
function rejectsEffort(provider: unknown, model: unknown, effort: unknown): boolean {
if (typeof effort !== 'string' || effort === '') return false
if (typeof provider !== 'string' || typeof model !== 'string' || !provider || !model) {
return false
}
const capability = getReasoningCapability(provider as AIProvider, model)
if (!capability.known) return false
const offToken = explicitOffToken(provider as AIProvider, model)
return carriedReasoning(effort, offToken, capability) === undefined
}
@@ -705,6 +705,7 @@
path={flow?.path ?? ''}
description={flow?.description}
inputSchema={flow?.schema}
flowModules={flow?.value?.modules}
wideLayout
/>
{:else}
+20 -5
View File
@@ -102,15 +102,26 @@ tool, `websearch` for web search.
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`
agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its
fields to flow inputs instead — see below
### Chat-Mode Flows
A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required `user_message` string
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
input, read by the agent. Any other flow input the composer does not edit itself is asked for
under Configure inputs.
**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs
instead, either way round: one input carrying the whole object (`"expr": "flow_input.model_config"`)
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer — a provider picker, a model list, a thinking
control — and a field left static is fixed, with no control drawn for it. `kind` is the one
exception: the composer writes it only together with `resource`, since a provider is picked as a
pair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run
needs becomes unreachable.
```json
{
@@ -119,8 +130,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
@@ -133,6 +144,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
}
```
- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`
references. A spread, a call or a computed key leaves the composer unable to tell which input
feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object
is read instead as that one input carrying every field
- `memory` is what lets the agent see earlier turns; without it every message starts from nothing
- `streaming` on makes the answer and its thinking appear token by token instead of all at once
- `user_attachments` points at a flow input typed as an array of s3 objects
+20 -5
View File
@@ -133,15 +133,26 @@ tool, \`websearch\` for web search.
}
\`\`\`
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
agent through \`value.agent\`
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
fields to flow inputs instead see below
### Chat-Mode Flows
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
input, read by the agent. Any other flow input the composer does not edit itself is asked for
under Configure inputs.
**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs
instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`)
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer a provider picker, a model list, a thinking
control and a field left static is fixed, with no control drawn for it. \`kind\` is the one
exception: the composer writes it only together with \`resource\`, since a provider is picked as a
pair, so a \`kind\` input wired on its own stays askable under Configure inputs and nothing the run
needs becomes unreachable.
\`\`\`json
{
@@ -150,8 +161,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
@@ -164,6 +175,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
}
\`\`\`
- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\`
references. A spread, a call or a computed key leaves the composer unable to tell which input
feeds which field, so it offers no control at all a bare \`flow_input.x\` for the whole object
is read instead as that one input carrying every field
- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing
- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once
- \`user_attachments\` points at a flow input typed as an array of s3 objects
@@ -190,15 +190,26 @@ tool, `websearch` for web search.
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`
agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its
fields to flow inputs instead — see below
### Chat-Mode Flows
A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required `user_message` string
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
input, read by the agent. Any other flow input the composer does not edit itself is asked for
under Configure inputs.
**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs
instead, either way round: one input carrying the whole object (`"expr": "flow_input.model_config"`)
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer — a provider picker, a model list, a thinking
control — and a field left static is fixed, with no control drawn for it. `kind` is the one
exception: the composer writes it only together with `resource`, since a provider is picked as a
pair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run
needs becomes unreachable.
```json
{
@@ -207,8 +218,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
@@ -221,6 +232,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
}
```
- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`
references. A spread, a call or a computed key leaves the composer unable to tell which input
feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object
is read instead as that one input carrying every field
- `memory` is what lets the agent see earlier turns; without it every message starts from nothing
- `streaming` on makes the answer and its thinking appear token by token instead of all at once
- `user_attachments` points at a flow input typed as an array of s3 objects
+20 -5
View File
@@ -102,15 +102,26 @@ tool, `websearch` for web search.
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`
agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its
fields to flow inputs instead — see below
### Chat-Mode Flows
A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required `user_message` string
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
input, read by the agent. Any other flow input the composer does not edit itself is asked for
under Configure inputs.
**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs
instead, either way round: one input carrying the whole object (`"expr": "flow_input.model_config"`)
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer — a provider picker, a model list, a thinking
control — and a field left static is fixed, with no control drawn for it. `kind` is the one
exception: the composer writes it only together with `resource`, since a provider is picked as a
pair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run
needs becomes unreachable.
```json
{
@@ -119,8 +130,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
@@ -133,6 +144,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf
}
```
- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`
references. A spread, a call or a computed key leaves the composer unable to tell which input
feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object
is read instead as that one input carrying every field
- `memory` is what lets the agent see earlier turns; without it every message starts from nothing
- `streaming` on makes the answer and its thinking appear token by token instead of all at once
- `user_attachments` points at a flow input typed as an array of s3 objects