import { LoadingState } from "../components/loading-state"; import { useEffect, useRef, useState } from "octane"; import { Button } from "octane-kumo/components/button"; import { Input } from "octane-kumo/components/input"; import { Select } from "octane-kumo/components/select"; import { ModelEffortSelect } from "../components/model-effort-select"; import type { ModelConfiguration, ModelSettings as SavedModels, } from "../../worker/model-settings"; import { useMutation } from "@octanejs/tanstack-query"; import { useCustomerQuerySession } from "../runtime/customer-query-provider"; import { callOwner, type SettingsConnectionProps } from "../runtime/owner-rpc"; import { useModelCatalogQuery } from "../runtime/queries/use-model-catalog-query"; import { modelSettingsOptions, useModelSettingsQuery, } from "../runtime/queries/use-model-settings-query"; import { MODEL_PROVIDERS, CREDENTIAL_PROVIDERS, isModelProvider, isCredentialProvider, modelLabel, type CredentialProvider, } from "../../shared/model-providers"; type Provider = ModelConfiguration["provider"]; export function ModelSettings({ connection, connected, connecting, }: SettingsConnectionProps) { const cache = useCustomerQuerySession(); const catalogQuery = useModelCatalogQuery(connection); const options = modelSettingsOptions(cache, connection); const savedQuery = useModelSettingsQuery(connection); const catalog = catalogQuery.data; const saved = savedQuery.data; const [draft, setDraft] = useState( saved?.configuration ?? null, ); const [key, setKey] = useState(""); const [keyProvider, setKeyProvider] = useState( saved && isCredentialProvider(saved.configuration.provider) ? saved.configuration.provider : "anthropic", ); const [keyBusy, setKeyBusy] = useState(false); const [keyError, setKeyError] = useState(""); const [notice, setNotice] = useState(""); const generation = useRef(0); const dirty = useRef(false); const current = useRef({ connection, connected }); current.current = { connection, connected }; const valid = (token: number) => generation.current === token && current.current.connected && current.current.connection === connection && !!connection?.isCurrent(); useEffect(() => { if (!saved || dirty.current) return; setDraft(saved.configuration); if (isCredentialProvider(saved.configuration.provider)) setKeyProvider(saved.configuration.provider); }, [saved]); const mutation = useMutation({ mutationFn: async (configuration: ModelConfiguration) => { const token = generation.current; if (!valid(token)) throw new Error("Connection unavailable"); await cache.client.cancelQueries({ queryKey: options.queryKey }); await cache.client.invalidateQueries({ queryKey: options.queryKey, refetchType: "none", }); const value = await callOwner( connection, "updateModelSettings", [configuration], ); if (!valid(token)) return; void cache.client.cancelQueries({ queryKey: options.queryKey }); cache.client.setQueryData(options.queryKey, value); setDraft(value.configuration); dirty.current = false; setNotice("Model saved. Applies from the next turn."); }, }); useEffect(() => { generation.current++; setKey(""); setKeyBusy(false); setKeyError(""); setNotice(""); mutation.reset(); return () => { generation.current++; }; }, [connection, connected]); const busy = keyBusy || mutation.isPending; const loading = catalogQuery.isPending || savedQuery.isPending; const loadFailed = catalogQuery.isError || savedQuery.isError; const error = keyError || (mutation.isError ? "Could not save the model. Your selection is still here. Try again." : loadFailed ? "Could not load model settings. Try loading them again." : ""); async function save(kind: "model" | "key" | "remove") { if (!connection?.isReady() || !connected || busy || loading || loadFailed) return; if ( (kind === "model" && draft?.provider === "openrouter" && saved?.providers?.openrouter !== true) || (kind === "key" && keyProvider === "openrouter" && saved?.providers?.openrouter !== true) ) return; setKeyError(""); setNotice(""); if (kind === "model") { if (draft) mutation.mutate(draft); return; } // Secrets stay on this explicit action path, outside mutation variables/cache. const token = generation.current; setKeyBusy(true); try { await cache.client.cancelQueries({ queryKey: options.queryKey }); await cache.client.invalidateQueries({ queryKey: options.queryKey, refetchType: "none", }); if (!valid(token)) return; const value = await callOwner(connection, "setProviderKey", [ keyProvider, kind === "remove" ? null : key, ]); if (!valid(token)) return; void cache.client.cancelQueries({ queryKey: options.queryKey }); cache.client.setQueryData(options.queryKey, value); setNotice( kind === "remove" ? `${MODEL_PROVIDERS[keyProvider].name} key removed.` : `${MODEL_PROVIDERS[keyProvider].name} key saved. The provider has not been contacted to validate it.`, ); } catch { if (valid(token)) setKeyError( `Could not update the ${MODEL_PROVIDERS[keyProvider].name} key. Re-enter the key to retry, or try removing it again.`, ); } finally { if (valid(token)) { setKey(""); setKeyBusy(false); } } } const locked = !connected || loading || loadFailed || busy; const changed = saved && draft && (saved.configuration.provider !== draft.provider || saved.configuration.model !== draft.model || saved.configuration.effort !== draft.effort); const openRouterEnabled = saved?.providers?.openrouter === true; const draftProviderEnabled = draft?.provider !== "openrouter" || openRouterEnabled; const keyProviderEnabled = keyProvider !== "openrouter" || openRouterEnabled; return (

Model and provider

Choose the installation default for new chats, conversations using the default, and scheduled tasks. Use the chat composer to override the model and effort for one conversation.

{loading && (connected || connecting) && !saved && !error && ( )} {(catalogQuery.isFetching || savedQuery.isFetching) && connected && saved &&

Refreshing model settings…

} {error &&

{error}

} {loadFailed && connected && ( )} {catalog && draft && saved && ( <>
{ event.preventDefault(); void save("model"); }} > ({ value: model, label: modelLabel(draft.provider, model), }))} value={draft.model} disabled={locked} onValueChange={(value) => { if (!value) return; dirty.current = true; setDraft({ provider: draft.provider, model: value, } as ModelConfiguration); setNotice(""); }} /> { dirty.current = true; setDraft(value); setNotice(""); }} />

{MODEL_PROVIDERS[draft.provider].description}

{draft.provider === "openrouter" && openRouterEnabled && (

OpenRouter is enabled for this installation.

)} {draft.provider === "openrouter" && !openRouterEnabled && (

{saved.providerSetupUrl ? ( <> OpenRouter is not enabled for this installation. Enable it in the control plane before saving an OpenRouter model or key.{" "} setKey("")} > Enable OpenRouter ) : ( "OpenRouter is unavailable because this installation is not connected to the control plane. Reconnect the installation, then return here to enable OpenRouter." )}

)} {isCredentialProvider(draft.provider) && saved.credentials[draft.provider] === "missing" && (

Add an {MODEL_PROVIDERS[draft.provider].name} key before using this model.

)}
{changed &&

Unsaved model selection

}
Usage and billing

Free allowance models share Workers AI’s 10,000 Neurons per day; additional usage requires payment. Paid Cloudflare models require a paid billing method. Gateway unified billing uses prepaid credits instead of the free allowance. OpenRouter and Anthropic bill separately.

Need more Cloudflare credits?{" "} Add AI Gateway credits {" "} (opens in a new tab).

{ event.preventDefault(); void save("key"); }} >

{MODEL_PROVIDERS[keyProvider].name} API key

{saved.configuration.provider === keyProvider && (

Removing the key stops future{" "} {MODEL_PROVIDERS[keyProvider].name} turns until you add a key or save a Workers AI model.

)}

Key drafts clear after a save attempt or lost connection. Re-enter the key to retry.

)} {notice &&

{notice}

}
); }