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:
commit
18ffbf15f6
4 changed files with 224 additions and 71 deletions
|
|
@ -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"]
|
||||
: []),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue