Merge remote-tracking branch 'origin/main' into feat/openai-image-generation-pill

# Conflicts:
#	studio/frontend/src/features/chat/api/chat-adapter.ts
#	studio/frontend/src/features/chat/stores/chat-runtime-store.ts
This commit is contained in:
Daniel Han 2026-05-22 14:07:55 +00:00
commit 18ffbf15f6
4 changed files with 224 additions and 71 deletions

View file

@ -870,6 +870,52 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
throw new Error("Missing connection API key.");
}
const webSearchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
providerSupportsBuiltinWebSearch(externalProvider.providerType),
);
const codeExecEnabledForThisTurn =
Boolean(
externalProvider &&
externalSelection &&
codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
),
);
// web_fetch shares the Search pill with web_search (no separate
// UI toggle), so it follows toolsEnabled. Anthropic is the only
// provider that ships it today; on others providerSupportsBuiltinWebFetch
// returns false and this stays inert.
const webFetchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
);
const providerShipsWebFetch = Boolean(
externalProvider &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
);
// OpenAI Responses-API image_generation server tool. Pill is
// gated on OpenAI cloud + a Responses-API model id; the backend
// additionally re-checks is_openai_cloud before appending
// {type:"image_generation"} to the request tools array.
const imageGenerationEnabledForThisTurn = Boolean(
externalProvider &&
externalSelection &&
imageToolsEnabled &&
providerSupportsBuiltinImageGeneration(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
),
);
const outboundMessages = messages
.map(toOpenAIMessage)
.filter((message): message is NonNullable<typeof message> =>
@ -884,6 +930,62 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
content: safeSystemPrompt.trim(),
});
}
let disabledToolGuard: string | null = null;
const disabledToolGuardProviderType = externalProvider?.providerType;
if (
disabledToolGuardProviderType === "anthropic" ||
disabledToolGuardProviderType === "openai"
) {
const webLabel = providerShipsWebFetch
? "web search or web fetch"
: "web search";
if (!webSearchEnabledForThisTurn && !codeExecEnabledForThisTurn) {
disabledToolGuard =
`You do not have ${webLabel} or code execution tools in this conversation. ` +
"Answer from your own knowledge. " +
"If a request genuinely requires tool use, live data fetch or running code, " +
"inform the user that you do not have access to these capabilities. " +
"Do not return tool-call syntax inside your response.";
} else if (!webSearchEnabledForThisTurn) {
disabledToolGuard =
`You do not have ${webLabel} tools in this conversation. ` +
"You may still use code execution tools when they are available and useful. " +
"If a request genuinely requires live data fetch or web search tool use, " +
"inform the user that you do not have access to these capabilities. " +
"Do not return tool-call syntax inside your response.";
} else if (!codeExecEnabledForThisTurn) {
disabledToolGuard =
"You do not have code execution tools in this conversation. " +
`You may still use ${webLabel} tools when they are available and useful. ` +
"If a request genuinely requires running code or code execution tool use, " +
"inform the user that you do not have access to these capabilities. " +
"Do not return tool-call syntax inside your response.";
}
}
if (disabledToolGuard) {
const firstMessage = outboundMessages[0];
if (firstMessage?.role === "system") {
if (typeof firstMessage.content === "string") {
outboundMessages[0] = {
...firstMessage,
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
};
} else {
outboundMessages[0] = {
...firstMessage,
content: [
...firstMessage.content,
{ type: "text", text: `\n\n${disabledToolGuard}` },
],
};
}
} else {
outboundMessages.unshift({
role: "system",
content: disabledToolGuard,
});
}
}
const imageBase64 = findLatestUserImageBase64(messages);
const audioBase64 = findLatestUserAudioBase64(messages);
@ -1138,13 +1240,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// (sent as `container` on /v1/messages).
let openaiCodeExecContainerId: string | null = null;
let anthropicCodeExecContainerId: string | null = null;
const codeExecEnabledForThisTurn =
codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
);
if (codeExecEnabledForThisTurn && resolvedThreadId) {
try {
const thread = await getStoredChatThread(resolvedThreadId);
@ -1314,31 +1409,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// translates enabled_tools into each provider's tool
// schema — for Anthropic that's the entries appended to
// body["tools"] inside _stream_anthropic.
...((toolsEnabled &&
providerSupportsBuiltinWebSearch(
externalProvider.providerType,
)) ||
(codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
)) ||
(imageToolsEnabled &&
providerSupportsBuiltinImageGeneration(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
))
...(webSearchEnabledForThisTurn ||
webFetchEnabledForThisTurn ||
codeExecEnabledForThisTurn ||
imageGenerationEnabledForThisTurn
? {
enable_tools: true,
enabled_tools: [
...(toolsEnabled &&
providerSupportsBuiltinWebSearch(
externalProvider.providerType,
)
? ["web_search"]
: []),
...(webSearchEnabledForThisTurn ? ["web_search"] : []),
// Pair web_fetch with the Search pill on any
// provider that ships it (Anthropic today). The
// common workflow is "search returns URLs, fetch
@ -1346,30 +1424,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// surface a citation but cannot quote from the
// page body, which is the whole point of the
// tool. There is no separate UI toggle yet.
...(toolsEnabled &&
providerSupportsBuiltinWebFetch(
externalProvider.providerType,
)
? ["web_fetch"]
: []),
...(codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
)
? ["code_execution"]
: []),
...(webFetchEnabledForThisTurn ? ["web_fetch"] : []),
...(codeExecEnabledForThisTurn ? ["code_execution"] : []),
// OpenAI Responses-API only: `image_generation`
// returns inline image_generation_call output
// items; the backend's _stream_openai_responses
// path translates them to assistant tool events.
...(imageToolsEnabled &&
providerSupportsBuiltinImageGeneration(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
)
...(imageGenerationEnabledForThisTurn
? ["image_generation"]
: []),
],

View file

@ -140,6 +140,13 @@ export async function deleteProviderConfig(providerId: string): Promise<void> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "DELETE",
});
// Treat 404 as success: another browser (or tab) already deleted this
// provider on the backend, so locally pruning the stale cache is the
// correct follow-up. Without this, the caller would throw and the user
// would be stuck with an entry they cannot remove from the UI.
if (response.status === 404) {
return;
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));

View file

@ -345,15 +345,21 @@ export function ChatProvidersSettings({
useEffect(() => {
let isMounted = true;
const syncFromBackend = async () => {
setRegistryLoading(true);
setSyncingProviders(true);
const syncFromBackend = async ({
showSpinner = true,
}: { showSpinner?: boolean } = {}) => {
if (showSpinner) {
setRegistryLoading(true);
setSyncingProviders(true);
}
let syncSucceeded = false;
try {
const [registryRows, configRows] = await Promise.all([
listProviderRegistry(),
listProviderConfigs(),
]);
if (!isMounted) return;
syncSucceeded = true;
setRegistry(registryRows);
setProviderType((current) => {
if (
@ -406,25 +412,45 @@ export function ChatProvidersSettings({
updatedAt,
};
});
// Don't wipe localStorage providers when the server has no rows.
if (syncedProviders.length === 0 && providersRef.current.length > 0) {
return;
}
// Trust the backend response when it succeeds. An empty array means
// every connection was removed (often from another browser/tab) and
// the local cache should mirror that, otherwise the stale entries
// become un-removable in this browser until localStorage is cleared.
onProvidersChange(syncedProviders);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
toast.error(`Failed to load connections: ${message}`);
// Only surface a toast for real failures, not for the silent
// background re-sync on tab focus.
if (showSpinner) {
const message =
error instanceof Error ? error.message : "Unknown error";
toast.error(`Failed to load connections: ${message}`);
}
} finally {
if (isMounted) {
if (isMounted && showSpinner) {
setRegistryLoading(false);
setSyncingProviders(false);
}
}
return syncSucceeded;
};
void syncFromBackend();
// Re-sync silently when the tab regains focus so deletes made in
// another browser propagate without forcing the user to reopen the
// dialog. Skip when the document is hidden to avoid background work.
const handleVisibilityChange = () => {
if (typeof document === "undefined" || document.hidden) return;
void syncFromBackend({ showSpinner: false });
};
if (typeof window !== "undefined") {
window.addEventListener("focus", handleVisibilityChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
}
return () => {
isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleVisibilityChange);
document.removeEventListener("visibilitychange", handleVisibilityChange);
}
};
}, [onProvidersChange]);

View file

@ -14,6 +14,7 @@ import {
DEFAULT_INFERENCE_PARAMS,
type InferenceParams,
} from "../types/runtime";
import { isExternalModelId } from "../external-providers";
import {
loadChatSettingsWithLegacyImport,
savePersistedChatSettingsPatch,
@ -25,6 +26,41 @@ export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
// External provider selection is encoded into `params.checkpoint` as
// `external::<providerId>::<modelId>`. PersistedChatSettings deliberately
// Omits `checkpoint` because the local-model side is mirrored by the
// backend's `/api/inference/status.active_model` response. External
// selections have no such backend mirror, so without explicit
// localStorage persistence here the user's external pick is silently
// reset to the default on every page refresh.
const LAST_EXTERNAL_CHECKPOINT_KEY = "unsloth_chat_last_external_checkpoint";
function loadLastExternalCheckpoint(): string | null {
if (typeof window === "undefined") return null;
try {
const value = window.localStorage.getItem(LAST_EXTERNAL_CHECKPOINT_KEY);
return isExternalModelId(value) ? value : null;
} catch {
return null;
}
}
function saveLastExternalCheckpoint(value: string | null): void {
if (typeof window === "undefined") return;
try {
if (value && isExternalModelId(value)) {
window.localStorage.setItem(LAST_EXTERNAL_CHECKPOINT_KEY, value);
} else {
// Clearing on a switch to a local / empty checkpoint means the
// next refresh won't override the now-active local selection.
window.localStorage.removeItem(LAST_EXTERNAL_CHECKPOINT_KEY);
}
} catch {
// Storage quota / private-mode failures are non-fatal -- the
// selection just won't survive the refresh.
}
}
export type ReasoningStyle = "enable_thinking" | "reasoning_effort";
export type ReasoningEffort =
| "none"
@ -489,7 +525,16 @@ function setScalarSettingVersion<K extends ScalarSettingKey>(
export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
settingsHydrated: false,
params: DEFAULT_INFERENCE_PARAMS,
// Hydrate the last external checkpoint into params.checkpoint so the
// external picker selection survives a page refresh. Local model
// checkpoints are re-derived from the backend in useChatModelRuntime
// and intentionally NOT persisted here.
params: (() => {
const persistedExternal = loadLastExternalCheckpoint();
return persistedExternal
? { ...DEFAULT_INFERENCE_PARAMS, checkpoint: persistedExternal }
: DEFAULT_INFERENCE_PARAMS;
})(),
customPresets: [],
activePreset: "Default",
activePresetSource: getPresetSource("Default"),
@ -652,18 +697,31 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
}),
setModelsError: (modelsError) => set({ modelsError }),
setCheckpoint: (modelId, ggufVariant) =>
set((state) => ({
params: {
...state.params,
checkpoint: modelId,
},
activeGgufVariant: ggufVariant ?? null,
})),
set((state) => {
// Persist external selections so they survive a page refresh.
// Local model ids are NOT persisted here -- they get re-derived
// from the backend's `/api/inference/status.active_model` on
// mount, and a stale persisted local id would race against the
// freshly-loaded model. See LAST_EXTERNAL_CHECKPOINT_KEY notes.
saveLastExternalCheckpoint(isExternalModelId(modelId) ? modelId : null);
return {
params: {
...state.params,
checkpoint: modelId,
},
activeGgufVariant: ggufVariant ?? null,
};
}),
setActiveThreadId: (activeThreadId) =>
set({ activeThreadId, contextUsage: null }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () =>
set((state) => ({
clearCheckpoint: () => {
// Mirror setCheckpoint's persistence behavior: dropping the
// checkpoint must also clear any stored external selection so
// the next refresh doesn't snap back to a model the user
// intentionally cleared.
saveLastExternalCheckpoint(null);
return set((state) => ({
params: {
...state.params,
checkpoint: "",
@ -701,7 +759,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
defaultChatTemplate: null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
})),
}));
},
setReasoningEnabled: (reasoningEnabled, options) =>
set(() => {
if (options?.persist !== false) {