Files
windmill/frontend/src/lib/components/AIProviderPicker.svelte
T
Ruben FiszelandClaude Opus 5.5 046df9e44d feat: run AI decision steps on OpenAI's Decisions API (#11590)
* feat: run AI decision steps on OpenAI's Decisions API

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

* fix: fail refused openai decisions and keep structured criteria

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

* fix: send only user message lists as openai decision messages

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

* fix: omit descriptions the openai decision step does not have

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

* fix: pass only exact openai user messages through as decision input

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

* fix: check the payload of openai decision message parts

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 12:05:04 +00:00

285 lines
8.2 KiB
Svelte

<script lang="ts">
import { type Snippet, untrack } from 'svelte'
import Select from './select/Select.svelte'
import {
fetchAvailableModels,
AI_PROVIDERS,
DECISION_AI_PROVIDERS,
aiProviderDetails
} from './copilot/lib'
import type { AIProvider, ProviderConfig } from '$lib/gen'
import ResourcePicker from './ResourcePicker.svelte'
import Toggle from './Toggle.svelte'
import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage'
import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value: ProviderConfig | undefined
disabled?: boolean
actions?: Snippet
/** The workspace the surface operates on, which a session or fork editor sets to something
* other than the one being navigated. Resources and the models read off them are per
* workspace, so without it this offers what the wrong one holds. */
workspace?: string | undefined
/** Offer the decision providers (TypeSafe, Cloudflare, OpenAI) and their decision models in
* place of the chat ones, for an AI decision. */
decision?: boolean
}
let {
value: _uncheckedValue = $bindable(),
disabled = false,
actions,
workspace = undefined,
decision = false
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
let value = $derived.by(() => {
if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined
return _uncheckedValue
})
let loading = $state(false)
let availableModels = $state<string[]>([])
let filterText = $state('')
// Keyed by provider *and* path: two `customai` resources point at different base URLs, so they
// do not share a model list.
let modelsCache = new Map<string, string[]>()
// The resource picker offers every provider type at once and the pick is what names the kind.
// One list for the component's life: it is what the picker queries with.
const offeredProviders = Object.keys(
untrack(() => decision) ? DECISION_AI_PROVIDERS : AI_PROVIDERS
)
const providerResourceTypes = offeredProviders.join(',')
if (!_uncheckedValue) {
_uncheckedValue = {
kind: untrack(() => decision) ? 'typesafe' : 'openai',
resource: '',
model: ''
}
}
let useAsDefault = $derived(isSameAsStoredConfig(value))
// Reactive items for the Select component
let items = $derived.by(() => {
const r = availableModels.map((model) => ({
label: model,
value: model
}))
if (value?.model && !availableModels.find((model) => model === value.model)) {
r.push({
label: value.model,
value: value.model
})
}
return r
})
// A decision provider's models are the ones Windmill knows: none has a listing of its decision
// models, and an OpenAI resource's lists its chat models.
function defaultModels(provider: AIProvider): string[] {
return decision
? ((DECISION_AI_PROVIDERS as Record<string, { defaultModels: string[] }>)[provider]
?.defaultModels ?? [])
: aiProviderDetails(provider).defaultModels
}
async function loadModels(signal?: AbortSignal) {
const provider = value?.kind
const resourceValue = value?.resource
const resourcePath = resourceValueToPath(resourceValue)
if (!provider || !resourcePath) {
return
}
if (decision) {
availableModels = defaultModels(provider)
loading = false
return
}
loading = true
const cacheKey = `${effectiveWorkspace}:${provider}:${resourcePath}`
if (modelsCache.has(cacheKey)) {
availableModels = modelsCache.get(cacheKey) || []
loading = false
return
}
try {
const models = await fetchAvailableModels(resourcePath, effectiveWorkspace, provider, signal)
if (signal?.aborted) {
return
}
availableModels = models
modelsCache.set(cacheKey, models)
} catch (e) {
if (signal?.aborted) {
return
}
// Fall back to default models for this provider
availableModels = defaultModels(provider)
} finally {
if (!signal?.aborted) {
loading = false
}
}
}
/**
* The provider kind follows the resource that was picked. Driven by the pick rather than by an
* effect on the picker's `valueType`, which also resolves for the value the field was opened on
* and would rewrite a saved config just for being looked at.
*/
function onResourcePicked(_path: string | undefined, type: string | undefined) {
// An empty type is the placeholder the picker keeps for a saved path it could not find. It
// says nothing about the provider, so the kind stands.
if (!value || !type || !offeredProviders.includes(type)) {
return
}
if (value.kind === type) {
return
}
value.kind = type as AIProvider
// Models are per provider, and a reasoning token is per model.
value.model = ''
value.reasoning_effort = undefined
}
// Helper functions to handle $res: prefix like ObjectResourceInput does
function isResource(resourceValue: any): boolean {
return (
typeof resourceValue === 'string' &&
resourceValue.length >= '$res:'.length &&
resourceValue.startsWith('$res:')
)
}
function resourceValueToPath(resourceValue: any): string | undefined {
if (isResource(resourceValue)) {
return resourceValue.substring('$res:'.length)
}
return resourceValue
}
function pathToResourceValue(path: string | undefined): string | undefined {
if (path == undefined) {
return undefined
} else {
return `$res:${path}`
}
}
// Reload models when provider or resourcePath changes
$effect(() => {
const abortController = new AbortController()
const provider = value?.kind
const resourceValue = value?.resource
const resourcePath = resourceValueToPath(resourceValue)
filterText = ''
if (provider && resourcePath) {
loadModels(abortController.signal)
} else {
availableModels = provider ? defaultModels(provider) : []
loading = false
}
return () => {
abortController.abort()
}
})
$effect(() => {
if (useAsDefault && value && value.kind && value.resource && value.model) {
saveConfig(value)
}
})
</script>
<div class="w-full flex flex-col gap-3 border rounded-md p-4">
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Resource</span>
<!-- No auto-select: this picker spans every provider type, so a single candidate means "the
only AI resource in the workspace" rather than "the only one of the kind this agent uses".
Taking it would redefine the agent's provider and drop its model, on open and unasked. -->
<ResourcePicker
bind:value={
() => resourceValueToPath(value?.resource),
(v) => {
if (value) {
value.resource = pathToResourceValue(v) ?? ''
}
}
}
resourceType={providerResourceTypes}
{disabled}
{workspace}
placeholder="Select an AI provider resource"
selectFirst={false}
onValueChange={onResourcePicked}
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Model</span>
<Select
{items}
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
placeholder="Select model"
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
onCreateItem={(r) => {
availableModels.push(r)
if (value) value.model = r
}}
createText="Press enter to use custom model"
{loading}
clearable={false}
noItemsMsg={'No models available'}
bind:filterText
/>
</div>
{#if value?.model && !decision}
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Reasoning effort</span>
<AIReasoningEffortPicker
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
providerConfig={value}
{disabled}
/>
</div>
{/if}
<!-- The default seeds new agents, which a decision provider cannot run. -->
{#if !decision}
<div class="flex justify-end">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
{/if}
{@render actions?.()}
</div>