diff --git a/__tests__/integration/chats/chatDeleteRemoteImages.rendered.test.tsx b/__tests__/integration/chats/chatDeleteRemoteImages.rendered.test.tsx new file mode 100644 index 000000000..1606f404a --- /dev/null +++ b/__tests__/integration/chats/chatDeleteRemoteImages.rendered.test.tsx @@ -0,0 +1,99 @@ +/** + * Deleting a chat removes the images generated in it, whatever their format (backlog item 3). + * + * A remote server can return JPEG or WebP. The native image store only knows .png, so the + * delete must use each image's saved path. The real chat and app stores and the real chats screen + * run; the device filesystem and the diffusion module are the in-memory native boundary. + */ +import { + installNativeBoundary, + requireRTL, +} from '../../harness/nativeBoundary'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }), + useFocusEffect: () => {}, + useIsFocused: () => true, +})); + +describe('Deleting a chat with generated images', () => { + it('removes its remote JPEG and WebP files and keeps another chat’s image', async () => { + const boundary = installNativeBoundary({ fs: true }); + const fs = boundary.fs!; + const AsyncStorage = require('@react-native-async-storage/async-storage'); + await AsyncStorage.clear(); + const updatedAt = '2026-09-15T12:00:00.000Z'; + const chat = (id: string, title: string) => ({ + id, + title, + modelId: 'remote-model', + messages: [ + { id: `${id}-m`, role: 'user', content: title, timestamp: Date.parse(updatedAt) }, + ], + createdAt: updatedAt, + updatedAt, + }); + await AsyncStorage.setItem( + 'local-llm-chat-storage', + JSON.stringify({ + state: { + conversations: [chat('chat-trip', 'Trip photos'), chat('chat-logo', 'Logo ideas')], + activeConversationId: null, + }, + version: 2, + }), + ); + + const React = require('react'); + const rtl = requireRTL(); + const { useChatStore } = require('../../../src/stores/chatStore'); + const { useAppStore } = require('../../../src/stores/appStore'); + await useChatStore.persist.rehydrate(); + + const imagesDir = `${fs.DocumentDirectoryPath}/generated_images`; + const saveImage = (id: string, extension: string, conversationId: string) => { + const imagePath = `${imagesDir}/${id}.${extension}`; + fs.seedFile(imagePath, 2048); + useAppStore.getState().addGeneratedImage({ + id, + prompt: 'a lake at dawn', + imagePath, + width: 512, + height: 512, + steps: 1, + seed: 1, + modelId: 'remote-model', + createdAt: updatedAt, + conversationId, + }); + return imagePath; + }; + const tripJpeg = saveImage('img-trip-1', 'jpg', 'chat-trip'); + const tripWebp = saveImage('img-trip-2', 'webp', 'chat-trip'); + const logoJpeg = saveImage('img-logo-1', 'jpg', 'chat-logo'); + + const { ChatsListScreen } = require('../../../src/screens/ChatsListScreen'); + const chats = rtl.render(React.createElement(ChatsListScreen)); + + rtl.fireEvent.press(chats.getByLabelText('Select chats')); + rtl.fireEvent.press(chats.getByText('Trip photos')); + expect(chats.getByText('1 selected')).toBeTruthy(); + rtl.fireEvent.press(chats.getByLabelText('Delete selected chats')); + await rtl.act(() => new Promise(resolve => setTimeout(resolve, 350))); + rtl.fireEvent.press(chats.getByText('Delete')); + + await rtl.waitFor(async () => { + expect(await fs.exists(tripJpeg)).toBe(false); + expect(await fs.exists(tripWebp)).toBe(false); + }); + expect(await fs.exists(logoJpeg)).toBe(true); + expect(chats.queryByText('Trip photos')).toBeNull(); + expect(chats.getByText('Logo ideas')).toBeTruthy(); + await rtl.act(() => new Promise(resolve => setTimeout(resolve, 250))); + }); +}); diff --git a/__tests__/integration/generation/remoteModelFailureCopy.rendered.integration.test.tsx b/__tests__/integration/generation/remoteModelFailureCopy.rendered.integration.test.tsx index 32525496e..90430d3de 100644 --- a/__tests__/integration/generation/remoteModelFailureCopy.rendered.integration.test.tsx +++ b/__tests__/integration/generation/remoteModelFailureCopy.rendered.integration.test.tsx @@ -3,8 +3,17 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { RemoteModelOptionsSection } from '../../../src/components/models/RemoteModelOptionsSection'; import { remoteServerManager } from '../../../src/services/remoteServerManager'; +// The Desktop's HTTP API is the only boundary. It answers "unreachable" from the start of every test, +// so nothing here can reach a real LAN address (on a CI runner that hangs instead of failing fast). +const unreachable = (async () => { throw new TypeError('Network request failed'); }) as typeof fetch; +// A loaded CI runner renders these screens several times slower than a laptop. +const RENDER_WAIT = { timeout: 8000 }; + describe('remote model choice failures', () => { const originalFetch = global.fetch; + beforeEach(() => { + global.fetch = unreachable; + }); afterEach(async () => { global.fetch = originalFetch; await remoteServerManager.clearAllServers(); @@ -26,13 +35,12 @@ describe('remote model choice failures', () => { it('says what still works when the Desktop cannot be reached', async () => { const { view, choose } = await showImageChoice(); - global.fetch = (async () => { throw new TypeError('Network request failed'); }) as typeof fetch; choose(); await waitFor(() => { expect(view.queryByText(/Could not reach Office Desktop\. Models on this phone still work/)).not.toBeNull(); - }); + }, RENDER_WAIT); view.unmount(); - }); + }, 30000); it('shows the Desktop rejection instead of calling it offline', async () => { const { view, choose } = await showImageChoice(); @@ -44,8 +52,8 @@ describe('remote model choice failures', () => { choose(); await waitFor(() => { expect(view.queryByText('This model is not available to this device.')).not.toBeNull(); - }); + }, RENDER_WAIT); expect(view.queryByText(/Could not reach Office Desktop/)).toBeNull(); view.unmount(); - }); + }, 30000); }); diff --git a/__tests__/integration/generation/remoteReasoningDropped.rendered.redflow.test.tsx b/__tests__/integration/generation/remoteReasoningDropped.rendered.redflow.test.tsx index 9b8baa716..db9853a5f 100644 --- a/__tests__/integration/generation/remoteReasoningDropped.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/remoteReasoningDropped.rendered.redflow.test.tsx @@ -30,6 +30,10 @@ const LM_STUDIO_SSE = 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' + 'data: [DONE]\n\n'; +// A loaded CI runner renders the streamed turn several times slower than a laptop; the waits below +// are headroom for that, not for the behaviour under test. +const RENDER_WAIT = { timeout: 20000 }; + describe('T049 (rendered) — remote LM Studio reasoning is shown (DEV-B16)', () => { it('renders the answer and the reasoning the remote model streamed', async () => { const h = await setupChatScreen({ engine: 'llama', platform: 'android' }); @@ -41,13 +45,13 @@ describe('T049 (rendered) — remote LM Studio reasoning is shown (DEV-B16)', () await h.tapSend('what is 6 times 7'); // The remote answer arrives (proves the remote send + transport ran). - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The answer is 42/)).not.toBeNull(); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The answer is 42/)).not.toBeNull(); }, RENDER_WAIT); h.rtl.fireEvent.press(h.view!.getByTestId('assistant-work-toggle')); // SPEC: the reasoning the model actually sent is shown to the user (in the thinking block). // The panel can appear after the streamed answer; wait for its rendered state. await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Thinking Process/)).not.toBeNull(); - }, { timeout: 6000 }); - }); + }, RENDER_WAIT); + }, 60000); }); diff --git a/__tests__/integration/remote/keyedHttpServer.test.ts b/__tests__/integration/remote/keyedHttpServer.test.ts new file mode 100644 index 000000000..55ebbef23 --- /dev/null +++ b/__tests__/integration/remote/keyedHttpServer.test.ts @@ -0,0 +1,70 @@ +/** + * A saved API key on a private HTTP server (backlog item 1b). + * + * Keys are only sent over HTTPS, so on plain HTTP the app never uses the saved key. Every check + * must say so instead of reporting "Connected" or "the server rejected your key". The real + * endpoint check and model discovery run; the remote server is the only fake, answering at the + * fetch boundary the way a keyed llama.cpp/Ollama server on the LAN would. + */ +import { testEndpoint } from '../../../src/services/httpClientUtils'; +import { fetchModelsFromServer } from '../../../src/stores/remoteServerHelpers'; +import { HTTP_API_KEY_ERROR } from '../../../src/services/remoteTransportPolicy'; +import type { RemoteServer } from '../../../src/types/remoteServer'; + +const ENDPOINT = 'http://192.168.1.40:11434'; +const realFetch = globalThis.fetch; + +/** A LAN server that lists its models openly but refuses every other request without a key. */ +function startLanServer(): { requests: string[] } { + const requests: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + requests.push(`${init?.method ?? 'GET'} ${url.pathname}`); + if (url.pathname === '/v1/models') { + return new Response(JSON.stringify({ models: [{ name: 'llama3.2:3b' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('Unauthorized', { status: 401 }); + }) as typeof globalThis.fetch; + return { requests }; +} + +const server = (apiKey?: string): RemoteServer => ({ + id: 'lan-ollama', + name: 'LAN Ollama', + endpoint: ENDPOINT, + apiKey, + providerType: 'openai-compatible', + createdAt: '2026-09-28T10:00:00.000Z', +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe('a saved key on a private HTTP server', () => { + it('fails the connection check with the HTTPS message, without contacting the server', async () => { + const lan = startLanServer(); + + const result = await testEndpoint(ENDPOINT, 5000, 'saved-key'); + + expect(result).toEqual({ success: false, error: HTTP_API_KEY_ERROR }); + expect(lan.requests).toEqual([]); + }); + + it('still connects to the same server when no key is saved', async () => { + startLanServer(); + + const result = await testEndpoint(ENDPOINT, 5000); + + expect(result.success).toBe(true); + }); + + it('explains a refused capability check with the HTTPS rule, not as a rejected key', async () => { + startLanServer(); + + await expect(fetchModelsFromServer(server('saved-key'))).rejects.toThrow(HTTP_API_KEY_ERROR); + }); +}); diff --git a/__tests__/integration/remote/remoteSavedKey.test.ts b/__tests__/integration/remote/remoteSavedKey.test.ts new file mode 100644 index 000000000..26ac73472 --- /dev/null +++ b/__tests__/integration/remote/remoteSavedKey.test.ts @@ -0,0 +1,187 @@ +/** + * A saved API key on a remote server (backlog items 1a and 1b). + * + * The user saves a keyed server, checks it, loads its models, restarts the app and picks a model. + * Every step must use the saved key, so models keep their tools and context. A wrong key must read + * as a refused key, not as a server with no models, and must not erase the models already known. + * + * The real remote-server manager, stores and capability probes run. The fakes sit only at the true + * external boundaries: a llama.cpp server that requires its key (at fetch), the device Keychain, + * and app storage, which persists across the simulated restart. + */ + +const ENDPOINT = 'https://llm.example.test/v1'; +const GOOD_KEY = 'sk-live-correct'; +const MODEL = 'acme-assistant-8b'; + +type Keychain = Map; +const keychainHolder = globalThis as unknown as { __remoteKeychain?: Keychain }; + +jest.mock('react-native-keychain', () => { + const store = (): Map => { + const holder = globalThis as unknown as { + __remoteKeychain?: Map; + }; + holder.__remoteKeychain ??= new Map(); + return holder.__remoteKeychain; + }; + return { + ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, + setGenericPassword: async (username: string, password: string, opts: { service: string }) => { + store().set(opts.service, { username, password }); + return true; + }, + getGenericPassword: async (opts: { service: string }) => store().get(opts.service) ?? false, + resetGenericPassword: async (opts: { service: string }) => store().delete(opts.service), + }; +}); + +const realFetch = globalThis.fetch; +const LAN_ENDPOINT = 'http://192.168.1.40:8080/v1'; + +/** A llama.cpp server on the LAN over plain HTTP that needs no key to list its models. */ +function startOpenLanServer(): void { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + if (url.pathname === '/v1/models') { + return new Response(JSON.stringify({ object: 'list', data: [{ id: MODEL, object: 'model' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('not found', { status: 404 }); + }) as typeof globalThis.fetch; +} + +/** A llama.cpp server on HTTPS that answers only requests carrying its key. */ +function startKeyedServer(): { requests: Array<{ path: string; authorized: boolean }> } { + const requests: Array<{ path: string; authorized: boolean }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + const headers = new Headers(init?.headers); + const authorized = headers.get('Authorization') === `Bearer ${GOOD_KEY}`; + requests.push({ path: url.pathname, authorized }); + if (!authorized) return new Response('{"error":"invalid api key"}', { status: 401 }); + const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + if (url.pathname === '/v1/models') return json({ object: 'list', data: [{ id: MODEL, object: 'model' }] }); + if (url.pathname === '/props') { + return json({ + default_generation_settings: { n_ctx: 32768 }, + chat_template_caps: { supports_tools: true }, + modalities: { vision: false }, + }); + } + return new Response('not found', { status: 404 }); + }) as typeof globalThis.fetch; + return { requests }; +} + +function loadApp() { + const { remoteServerManager } = require('../../../src/services/remoteServerManager'); + const { useRemoteServerStore } = require('../../../src/stores/remoteServerStore'); + return { remoteServerManager, useRemoteServerStore }; +} + +const knownModel = (useRemoteServerStore: { getState: () => any }, serverId: string) => + useRemoteServerStore.getState().discoveredModels[serverId]?.find((m: { id: string }) => m.id === MODEL); + +beforeEach(async () => { + keychainHolder.__remoteKeychain = new Map(); + jest.resetModules(); + const AsyncStorage = require('@react-native-async-storage/async-storage'); + await AsyncStorage.clear(); +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe('a saved key on an HTTPS server', () => { + it('is used for the check, the models and their tools, across a restart and a model pick', async () => { + const server = startKeyedServer(); + const first = loadApp(); + + const saved = await first.remoteServerManager.addServer({ + name: 'Studio llama.cpp', + endpoint: ENDPOINT, + providerType: 'openai-compatible', + apiKey: GOOD_KEY, + }); + // The key lives in the Keychain, never in the saved server record. + expect(first.useRemoteServerStore.getState().getServerById(saved.id).apiKey).toBeUndefined(); + + await expect(first.remoteServerManager.testConnection(saved.id)).resolves.toMatchObject({ success: true }); + await first.remoteServerManager.discoverModels(saved.id); + expect(knownModel(first.useRemoteServerStore, saved.id)).toMatchObject({ + capabilities: { supportsToolCalling: true, maxContextLength: 32768 }, + }); + + // Restart: fresh modules over the same app storage and Keychain. + await new Promise(resolve => setTimeout(resolve, 0)); + jest.resetModules(); + const second = loadApp(); + await second.useRemoteServerStore.persist.rehydrate(); + await second.remoteServerManager.initializeProviders(); + expect(knownModel(second.useRemoteServerStore, saved.id)).toMatchObject({ + capabilities: { supportsToolCalling: true, maxContextLength: 32768 }, + }); + + await second.remoteServerManager.setActiveRemoteTextModel(saved.id, MODEL); + expect(second.useRemoteServerStore.getState().activeRemoteTextModelId).toBe(MODEL); + expect(knownModel(second.useRemoteServerStore, saved.id)).toMatchObject({ + capabilities: { supportsToolCalling: true }, + }); + + expect(server.requests.filter(request => !request.authorized)).toEqual([]); + }); + + it('reports a wrong key as a refused key and keeps the models it already knew', async () => { + startKeyedServer(); + const { remoteServerManager, useRemoteServerStore } = loadApp(); + const saved = await remoteServerManager.addServer({ + name: 'Studio llama.cpp', + endpoint: ENDPOINT, + providerType: 'openai-compatible', + apiKey: GOOD_KEY, + }); + await remoteServerManager.discoverModels(saved.id); + + await remoteServerManager.updateServer(saved.id, { apiKey: 'sk-live-wrong' }); + + const check = await remoteServerManager.testConnection(saved.id); + expect(check.success).toBe(false); + expect(check.error).toMatch(/rejected the API key/i); + await expect(remoteServerManager.discoverModels(saved.id)).rejects.toThrow(); + expect(knownModel(useRemoteServerStore, saved.id)).toMatchObject({ + capabilities: { supportsToolCalling: true }, + }); + }); +}); + +describe('a saved key on a private HTTP server', () => { + it('explains that keys need HTTPS, keeps the saved models, and a keyless setup still works', async () => { + const { HTTP_API_KEY_ERROR } = require('../../../src/services/remoteTransportPolicy'); + startOpenLanServer(); + const { remoteServerManager, useRemoteServerStore } = loadApp(); + const saved = await remoteServerManager.addServer({ + name: 'Desk llama.cpp', + endpoint: LAN_ENDPOINT, + providerType: 'openai-compatible', + }); + await expect(remoteServerManager.testConnection(saved.id)).resolves.toMatchObject({ success: true }); + await remoteServerManager.discoverModels(saved.id); + expect(knownModel(useRemoteServerStore, saved.id)).toBeTruthy(); + + await remoteServerManager.updateServer(saved.id, { apiKey: 'sk-lan' }); + + await expect(remoteServerManager.testConnection(saved.id)).resolves.toMatchObject({ + success: false, + error: HTTP_API_KEY_ERROR, + }); + expect(knownModel(useRemoteServerStore, saved.id)).toBeTruthy(); + + await remoteServerManager.updateServer(saved.id, { apiKey: '' }); + await expect(remoteServerManager.testConnection(saved.id)).resolves.toMatchObject({ success: true }); + }); +}); diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index e53cbbd28..99f5f7878 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -690,7 +690,7 @@ export async function startGenerationFn( !deps.activeModelInfo?.isRemote && deps.activeModel && !(await ensureReadyOrAlert(deps, 'startGeneration', () => { - startGenerationFn(deps, call); + void startGenerationFn(deps, call); })) ) { generationSession.end('not-ready'); @@ -847,7 +847,7 @@ export async function startGenerationFn( message: 'The model returned nothing. This can happen when it runs on an incompatible backend (a K-quant on NPU/GPU falls back to CPU and may emit nothing). Try again, or switch the backend/model.', onRetry: () => { - startGenerationFn(deps, call); + void startGenerationFn(deps, call); }, }); } @@ -1073,8 +1073,10 @@ export async function executeDeleteConversationFn( await generationService.stopGeneration(); deps.clearStreamingMessage(); } + // Read each image's saved path first: remote .jpg/.webp files are found by it, not by id. + const imagePaths = new Map(useAppStore.getState().generatedImages.map(image => [image.id, image.imagePath])); for (const id of deps.removeImagesByConversationId(deps.activeConversationId)) - await onnxImageGeneratorService.deleteGeneratedImage(id); + await onnxImageGeneratorService.deleteGeneratedImage(id, imagePaths.get(id)); contextCompactionService.clearSummary(deps.activeConversationId); deps.deleteConversation(deps.activeConversationId); deps.setActiveConversation(null); @@ -1151,7 +1153,7 @@ export async function regenerateResponseFn( !deps.activeModelInfo?.isRemote && deps.activeModel && !(await ensureReadyOrAlert(deps, 'regenerate', () => { - regenerateResponseFn(deps, call); + void regenerateResponseFn(deps, call); })) ) { generationSession.end('not-ready'); diff --git a/src/screens/ChatsListScreen.tsx b/src/screens/ChatsListScreen.tsx index 184779452..9f05fa4df 100644 --- a/src/screens/ChatsListScreen.tsx +++ b/src/screens/ChatsListScreen.tsx @@ -42,6 +42,10 @@ export const ChatsListScreen: React.FC = () => { const { getProject } = useProjectStore(); const activeImageModelId = useAppStore(s => s.activeImageModelId); const { removeImagesByConversationId } = useAppStore.getState(); + // Remote images can be .jpg or .webp; deletion needs each image's saved file path, read + // before the records are removed. + const savedImagePaths = () => + new Map(useAppStore.getState().generatedImages.map(image => [image.id, image.imagePath])); const { modelId: activeTextModelId } = useActiveTextModel(); const [alertState, setAlertState] = useState(initialAlertState); const [showModelSelector, setShowModelSelector] = useState(false); @@ -103,9 +107,10 @@ export const ChatsListScreen: React.FC = () => { style: 'destructive', onPress: () => { setAlertState(hideAlert()); + const imagePaths = savedImagePaths(); const imageIds = removeImagesByConversationId(conversation.id); for (const imageId of imageIds) { - onnxImageGeneratorService.deleteGeneratedImage(imageId).catch(() => {}); + onnxImageGeneratorService.deleteGeneratedImage(imageId, imagePaths.get(imageId)).catch(() => {}); } deleteConversation(conversation.id); }, @@ -132,10 +137,11 @@ export const ChatsListScreen: React.FC = () => { style: 'destructive', onPress: () => { setAlertState(hideAlert()); + const imagePaths = savedImagePaths(); for (const conversation of selected) { const imageIds = removeImagesByConversationId(conversation.id); for (const imageId of imageIds) { - onnxImageGeneratorService.deleteGeneratedImage(imageId).catch(() => {}); + onnxImageGeneratorService.deleteGeneratedImage(imageId, imagePaths.get(imageId)).catch(() => {}); } deleteConversation(conversation.id); } diff --git a/src/screens/RemoteServersScreen.tsx b/src/screens/RemoteServersScreen.tsx index 622a8924c..85023c7a4 100644 --- a/src/screens/RemoteServersScreen.tsx +++ b/src/screens/RemoteServersScreen.tsx @@ -52,8 +52,7 @@ export const RemoteServersScreen: React.FC = () => { const navigation = useNavigation(); const theme = useTheme(); const styles = useThemedStyles(createStyles); - const { servers, serverHealth, testConnection, activeServerId, setActiveServerId } = - useRemoteServerStore(); + const { servers, serverHealth, activeServerId, setActiveServerId } = useRemoteServerStore(); const autoDiscover = useAppStore( s => s.settings.autoDiscoverRemoteModels === true, ); @@ -69,10 +68,11 @@ export const RemoteServersScreen: React.FC = () => { const [scanFound, setScanFound] = useState(0); const [alertState, setAlertState] = useState(initialAlertState); - // Auto-check all server statuses when screen opens + // Auto-check all server statuses when screen opens. The manager reads each server's saved key, + // so an authenticated server is checked with its key rather than reported offline without it. useEffect(() => { servers.forEach(server => { - testConnection(server.id).catch(() => { }); + remoteServerManager.testConnection(server.id).catch(() => { }); }); // Status refresh belongs to this screen-open event, not every health projection update. @@ -82,7 +82,7 @@ export const RemoteServersScreen: React.FC = () => { const handleTestServer = useCallback(async (serverId: string) => { setTestingId(serverId); try { - const result = await testConnection(serverId); + const result = await remoteServerManager.testConnection(serverId); // The row's own status line already says Connected or Offline, so a success needs no // dialog to dismiss. Only a failure earns one, because it carries the reason. if (!result.success) { @@ -93,7 +93,7 @@ export const RemoteServersScreen: React.FC = () => { } finally { setTestingId(null); } - }, [testConnection]); + }, []); const handleScanNetwork = useCallback(async () => { setIsScanning(true); diff --git a/src/services/httpClientUtils.ts b/src/services/httpClientUtils.ts index b6ba67693..c1c4a4a15 100644 --- a/src/services/httpClientUtils.ts +++ b/src/services/httpClientUtils.ts @@ -4,6 +4,8 @@ import { isTailscaleIPv4 } from '../utils/network'; import { + HTTP_API_KEY_ERROR, + keyedHttpEndpoint, REMOTE_FETCH_REDIRECT_POLICY, remoteAuthorizationHeaders, } from './remoteTransportPolicy'; @@ -140,6 +142,9 @@ export async function testEndpoint( let url = endpoint; while (url.endsWith('/')) url = url.slice(0, -1); + // The saved key is never sent over HTTP, so a check that passed would not have used it. + if (keyedHttpEndpoint(url, apiKey)) return { success: false, error: HTTP_API_KEY_ERROR }; + const authHeaders: Record = { Accept: 'application/json', ...remoteAuthorizationHeaders(url, apiKey), @@ -149,7 +154,7 @@ export async function testEndpoint( const controller = new AbortController(); timeoutId = setTimeout(() => controller.abort(), timeout); - const response = await fetch(`${url}/v1/models`, { + const response = await fetch(`${url}${url.endsWith('/v1') ? '' : '/v1'}/models`, { method: 'GET', signal: controller.signal, headers: authHeaders, @@ -157,6 +162,18 @@ export async function testEndpoint( }); const latency = Date.now() - startTime; + // A refused key is the answer. A health page that answers 200 must not hide it. + if (response.status === 401 || response.status === 403) { + return { + success: false, + // On private HTTP the key is never sent, so the refusal is explained by the HTTPS rule. + error: keyedHttpEndpoint(url, apiKey) + ? HTTP_API_KEY_ERROR + : `The server rejected the API key (HTTP ${response.status}). Check the key for this server.`, + latency, + }; + } + if (!response.ok) { // Try alternate health endpoints const altUrls = ['/api/tags', '/health', '/']; diff --git a/src/services/localDreamGenerator.ts b/src/services/localDreamGenerator.ts index ce69749df..94daef172 100644 --- a/src/services/localDreamGenerator.ts +++ b/src/services/localDreamGenerator.ts @@ -250,7 +250,9 @@ class LocalDreamGeneratorService { storedImagePath, generatedImageDirectory, ); - const expectedPath = `${generatedImageDirectory}/${imageId}.png`; + // Local generation writes .png; remote generation can also save .jpg or .webp. + const extension = /\.(png|jpe?g|webp)$/i.exec(resolvedPath ?? '')?.[1]?.toLowerCase(); + const expectedPath = extension ? `${generatedImageDirectory}/${imageId}.${extension}` : null; if ( !resolvedPath || resolvedPath !== expectedPath || @@ -266,6 +268,13 @@ class LocalDreamGeneratorService { } catch { // Let the native store decide when the filesystem check is unavailable. } + + // The native store only knows .png. A remote image in another format is removed here, + // after the checks above proved the path is this image's file inside generated_images. + if (extension !== 'png') { + await RNFS.unlink(resolvedPath); + return true; + } } return await DiffusionModule.deleteGeneratedImage(imageId); diff --git a/src/services/remoteServerManager.ts b/src/services/remoteServerManager.ts index a69d78041..e05e82858 100644 --- a/src/services/remoteServerManager.ts +++ b/src/services/remoteServerManager.ts @@ -152,7 +152,14 @@ class RemoteServerManager { id: string, ): Promise<{ success: boolean; error?: string; models?: RemoteModel[] }> { const store = useRemoteServerStore.getState(); - const apiKey = await this.getApiKey(id); + let apiKey: string | null; + try { + apiKey = await this.getApiKey(id); + } catch (error) { + // The check could not run, so an earlier "Connected" must not stay on screen. + store.updateServerHealth(id, false); + throw error; + } return store.testConnection(id, apiKey || undefined); } diff --git a/src/services/remoteServerManagerUtils.ts b/src/services/remoteServerManagerUtils.ts index ed84f9155..0eab49202 100644 --- a/src/services/remoteServerManagerUtils.ts +++ b/src/services/remoteServerManagerUtils.ts @@ -167,7 +167,7 @@ export async function setActiveRemoteTextModelImpl( '— re-discovering', ); try { - await store.discoverModels(serverId); + await store.discoverModels(serverId, (await getApiKeyImpl(serverId)) ?? undefined); discoveredModel = store.getModelById(serverId, modelId); } catch (e) { logger.warn( @@ -291,7 +291,9 @@ export async function initializeProvidersImpl( // Re-discover models on startup to refresh capability data from the server // (persisted data may be stale if models were added/removed while offline) try { - const models = await store.discoverModels(server.id); + // Read after the migration above, so a key that just moved into Keychain is used. + const apiKey = (await getApiKeyImpl(server.id)) ?? undefined; + const models = await store.discoverModels(server.id, apiKey); logger.log( '[RemoteServerManager] Discovered', models.length, diff --git a/src/services/remoteTransportPolicy.ts b/src/services/remoteTransportPolicy.ts index 4721585a8..f70a03b6d 100644 --- a/src/services/remoteTransportPolicy.ts +++ b/src/services/remoteTransportPolicy.ts @@ -3,6 +3,15 @@ import { isTailscaleIPv4 } from '../utils/network'; export const PUBLIC_HTTP_REMOTE_ERROR = 'Remote HTTP servers must use a private LAN or Tailscale address.'; +export const HTTP_API_KEY_ERROR = + 'API keys are only sent over HTTPS. Use an https:// address for this server, or remove the key if the server does not need one.'; + +/** A saved key cannot be honoured over HTTP, so a keyed HTTP check fails with that reason + * instead of silently running without the key. */ +export function keyedHttpEndpoint(endpoint: string, apiKey?: string | null): boolean { + return !!apiKey && validateRemoteEndpoint(endpoint).protocol === 'http:'; +} + function isPrivateHost(hostname: string): boolean { if ( hostname === 'localhost' || diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index b7dd6c36d..eca2389bf 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -64,6 +64,11 @@ async function handleWebSearch(query: string): Promise { 'Accept': 'text/html', }, }); + // A refused or rate-limited search page has no results to parse. Say the search failed, + // instead of telling the model that the web has nothing on this query. + if (response.status >= 400) { + throw new Error(`Web search is unavailable right now (HTTP ${response.status}).`); + } const html = await response.text(); const results = parseBraveResults(html); diff --git a/src/stores/remoteModelCapabilities.ts b/src/stores/remoteModelCapabilities.ts index 058a57747..92f5c30b0 100644 --- a/src/stores/remoteModelCapabilities.ts +++ b/src/stores/remoteModelCapabilities.ts @@ -8,6 +8,25 @@ import logger from '../utils/logger'; import { templateEmitsReasoning, REASONING_DELIMITERS } from '../utils/messageContent'; +import { + remoteAuthorizationHeaders, + REMOTE_FETCH_REDIRECT_POLICY, +} from '../services/remoteTransportPolicy'; + +/** + * The server refused the request's credentials (401/403). A refused probe is not evidence that the + * model lacks a feature, so it is reported as a failure instead of an all-false capability record. + */ +export class RemoteAuthenticationError extends Error { + constructor(readonly status: number) { + super(`The server rejected the saved API key (HTTP ${status}). Check the key for this server.`); + this.name = 'RemoteAuthenticationError'; + } +} + +function isAuthRejection(status: number): boolean { + return status === 401 || status === 403; +} export interface RemoteModelInfo { contextLength: number; @@ -99,6 +118,7 @@ function extractOllamaCapabilities(data: Record): RemoteModelIn export async function fetchRemoteModelInfo( endpoint: string, modelName: string, + apiKey?: string, ): Promise { try { const controller = new AbortController(); @@ -106,18 +126,25 @@ export async function fetchRemoteModelInfo( const response = await fetch(`${endpoint}/api/show`, { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...remoteAuthorizationHeaders(endpoint, apiKey), + }, body: JSON.stringify({ name: modelName }), signal: controller.signal, + redirect: REMOTE_FETCH_REDIRECT_POLICY, }); clearTimeout(timeoutId); + if (isAuthRejection(response.status)) throw new RemoteAuthenticationError(response.status); if (!response.ok) return { contextLength: 4096, supportsVision: false }; const data = await response.json(); return extractOllamaCapabilities(data); - } catch { + } catch (error) { + if (error instanceof RemoteAuthenticationError) throw error; // Timeout, network error, parse error } @@ -132,6 +159,7 @@ export async function fetchRemoteModelInfo( export async function fetchLmStudioModelInfo( endpoint: string, modelId: string, + apiKey?: string, ): Promise { try { const controller = new AbortController(); @@ -139,12 +167,14 @@ export async function fetchLmStudioModelInfo( const response = await fetch(`${endpoint}/api/v1/models`, { method: 'GET', - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', ...remoteAuthorizationHeaders(endpoint, apiKey) }, signal: controller.signal, + redirect: REMOTE_FETCH_REDIRECT_POLICY, }); clearTimeout(timeoutId); + if (isAuthRejection(response.status)) throw new RemoteAuthenticationError(response.status); if (!response.ok) return { contextLength: 4096, supportsVision: false }; const data = await response.json(); @@ -171,7 +201,7 @@ export async function fetchLmStudioModelInfo( // LM Studio doesn't expose thinking capability in /api/v1/models. // Probe via a 1-token streaming request to learn whether THIS model thinks. - const supportsThinking = await probeLmStudioThinking(endpoint, modelId); + const supportsThinking = await probeLmStudioThinking(endpoint, modelId, apiKey); return { contextLength, @@ -186,7 +216,8 @@ export async function fetchLmStudioModelInfo( // merely flaked (timeout/network) during discovery. acceptsThinkingKwarg: true, }; - } catch { + } catch (error) { + if (error instanceof RemoteAuthenticationError) throw error; // Timeout, network error, parse error } @@ -221,7 +252,11 @@ function deltaHasThinking(delta: Record): boolean { return false; } -async function probeLmStudioThinking(endpoint: string, modelId: string): Promise { +async function probeLmStudioThinking( + endpoint: string, + modelId: string, + apiKey?: string, +): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); @@ -230,7 +265,8 @@ async function probeLmStudioThinking(endpoint: string, modelId: string): Promise // Read the full SSE response as text (RN fetch supports .text() but not ReadableStream). const response = await fetch(`${endpoint}/v1/chat/completions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + redirect: REMOTE_FETCH_REDIRECT_POLICY, + headers: { 'Content-Type': 'application/json', ...remoteAuthorizationHeaders(endpoint, apiKey) }, body: JSON.stringify({ model: modelId, messages: [{ role: 'user', content: 'Say hi' }], @@ -242,6 +278,7 @@ async function probeLmStudioThinking(endpoint: string, modelId: string): Promise }); clearTimeout(timeoutId); + if (isAuthRejection(response.status)) throw new RemoteAuthenticationError(response.status); if (!response.ok) return false; // response.text() collects the full SSE stream as a string @@ -259,6 +296,7 @@ async function probeLmStudioThinking(endpoint: string, modelId: string): Promise return false; } catch (error) { + if (error instanceof RemoteAuthenticationError) throw error; // Timeout, network error, model not loaded logger.warn('[probeLmStudioThinking] Failed to probe for thinking support:', error); } @@ -284,20 +322,24 @@ async function probeLmStudioThinking(endpoint: string, modelId: string): Promise */ export async function fetchLlamaCppProps( endpoint: string, + apiKey?: string, ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); try { const response = await fetch(`${endpoint}/props`, { method: 'GET', - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', ...remoteAuthorizationHeaders(endpoint, apiKey) }, signal: controller.signal, + redirect: REMOTE_FETCH_REDIRECT_POLICY, }); + if (isAuthRejection(response.status)) throw new RemoteAuthenticationError(response.status); if (!response.ok) return null; return parsePropsCapabilities(await response.json()); } catch (error) { + if (error instanceof RemoteAuthenticationError) throw error; // A non-llama.cpp server simply has no /props (network error / abort) — that's // expected and silent. Only an unexpected shape after a 200 is worth flagging, // but that path returns null from parsePropsCapabilities, not throw. Log at warn @@ -321,7 +363,13 @@ export async function fetchLlamaCppProps( const propsInFlight = new Map>(); /** De-duplicated wrapper around fetchLlamaCppProps — one /props call per endpoint. */ -export function fetchLlamaCppPropsCached(endpoint: string): Promise { +export function fetchLlamaCppPropsCached( + endpoint: string, + apiKey?: string, +): Promise { + // An authenticated probe is never shared: two saved servers at the same endpoint can hold + // different keys, and one must not receive a result fetched with the other's credentials. + if (apiKey) return fetchLlamaCppProps(endpoint, apiKey); // Deliberate in-flight-promise cache: return the pending promise un-awaited so concurrent // callers share one fetch. Explicit presence check (not a truthiness/await smell) so the // Promise-in-conditional rule (S6544) doesn't misread it as a forgotten await. @@ -429,20 +477,31 @@ export async function fetchModelCapabilities( endpoint: string, modelId: string, nameBasedDetect: { vision: (id: string) => boolean; toolCalling: (id: string) => boolean }, + apiKey?: string, ): Promise { - const [propsInfo, ollamaInfo, lmInfo] = await Promise.all([ + const [props, ollama, lm] = await Promise.allSettled([ // Deduped per endpoint — /props is server-wide, so all models on one server // share a single request instead of firing one each. - fetchLlamaCppPropsCached(endpoint), - fetchRemoteModelInfo(endpoint, modelId), - fetchLmStudioModelInfo(endpoint, modelId), + fetchLlamaCppPropsCached(endpoint, apiKey), + fetchRemoteModelInfo(endpoint, modelId, apiKey), + fetchLmStudioModelInfo(endpoint, modelId, apiKey), ]); + const propsInfo = props.status === 'fulfilled' ? props.value : null; + const ollamaInfo = ollama.status === 'fulfilled' ? ollama.value : null; + const lmInfo = lm.status === 'fulfilled' ? lm.value : null; // /props wins whenever it answered at all: on a llama.cpp server it is the // ground truth, even when every flag is false (a genuine text-only model). if (propsInfo) return propsInfo; - if (hasRealData(ollamaInfo)) return ollamaInfo; - if (hasRealData(lmInfo)) return lmInfo; + if (ollamaInfo && hasRealData(ollamaInfo)) return ollamaInfo; + if (lmInfo && hasRealData(lmInfo)) return lmInfo; + + // No probe returned real data. If one of them was refused for its credentials, the model's + // features are unknown, not absent: report the refusal so the last known data is kept. + const refused = [props, ollama, lm].find( + (result) => result.status === 'rejected' && result.reason instanceof RemoteAuthenticationError, + ); + if (refused?.status === 'rejected') throw refused.reason; // No API returned real data — fall back to name-based detection return { diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index f94a490d1..2c30cf700 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -18,12 +18,15 @@ import logger from '../utils/logger'; import { fetchModelCapabilities, isGenerativeModel, + RemoteAuthenticationError, } from './remoteModelCapabilities'; import { detectVisionCapability, detectToolCallingCapability, } from '../utils/remoteCapabilityDetect'; import { + HTTP_API_KEY_ERROR, + keyedHttpEndpoint, REMOTE_FETCH_REDIRECT_POLICY, remoteAuthorizationHeaders, } from '../services/remoteTransportPolicy'; @@ -344,11 +347,52 @@ export async function testEndpointAndGetModels( } } +/** + * Ollama-shaped models ({ name, details }) as RemoteModels, each probed for its capabilities with the + * server's key. Shared by the /v1/models and /api/tags paths, which list models the same way but + * filter them differently before calling this. + */ +async function mapOllamaModels( + models: Array<{ name: string; details?: Record }>, + probeBase: string, + server: RemoteServer, +): Promise { + const nameDetect = { + vision: detectVisionCapability, + toolCalling: detectToolCallingCapability, + }; + const modelInfos = await Promise.all( + models.map(model => + fetchModelCapabilities(probeBase, model.name, nameDetect, server.apiKey), + ), + ); + return models.map((model, i) => ({ + id: model.name, + name: displayModelName(model.name), + serverId: server.id, + capabilities: { + supportsVision: modelInfos[i].supportsVision, + supportsToolCalling: + modelInfos[i].supportsToolCalling ?? + detectToolCallingCapability(model.name), + supportsThinking: modelInfos[i].supportsThinking ?? false, + thinkingLevelsOnly: modelInfos[i].thinkingLevelsOnly, + acceptsThinkingKwarg: modelInfos[i].acceptsThinkingKwarg ?? false, + maxContextLength: modelInfos[i].contextLength, + }, + details: model.details, + lastUpdated: new Date().toISOString(), + })); +} + export async function fetchModelsFromServer( server: RemoteServer, ): Promise { const url = trimTrailingSlashes(server.endpoint); const isOpenRouter = new URL(url).hostname === 'openrouter.ai'; + // Capability probes (/props, /api/show, /api/v1/models) live beside /v1, not under it, so an + // address saved with a /v1 suffix probes from the base it was given. A proxy prefix stays. + const probeBase = url.endsWith('/v1') ? url.slice(0, -'/v1'.length) : url; // Headers for authentication const headers: Record = { @@ -366,6 +410,12 @@ export async function fetchModelsFromServer( headers, }); + // A refused key is an authentication failure, not "this server has no models". On private + // HTTP the key is never sent, so the refusal is explained by the HTTPS rule. + if (response.status === 401 || response.status === 403) { + if (keyedHttpEndpoint(server.endpoint, server.apiKey)) throw new Error(HTTP_API_KEY_ERROR); + throw new RemoteAuthenticationError(response.status); + } if (response.ok) { const data = await response.json(); @@ -395,7 +445,7 @@ export async function fetchModelsFromServer( supportsThinking: !!model.reasoning, thinkingLevelsOnly: model.reasoning?.mandatory === true, } - : fetchModelCapabilities(url, model.id, nameDetect), + : fetchModelCapabilities(probeBase, model.id, nameDetect, server.apiKey), ), ); return generativeModels.map( @@ -441,36 +491,18 @@ export async function fetchModelsFromServer( const generativeModels = data.models.filter( (model: { name: string; kind?: unknown }) => isTextModel(model), ); - const modelInfos = await Promise.all( - generativeModels.map((model: { name: string }) => - fetchModelCapabilities(url, model.name, nameDetect), - ), - ); - return generativeModels.map( - ( - model: { name: string; details?: Record }, - i: number, - ) => ({ - id: model.name, - name: displayModelName(model.name), - serverId: server.id, - capabilities: { - supportsVision: modelInfos[i].supportsVision, - supportsToolCalling: - modelInfos[i].supportsToolCalling ?? - detectToolCallingCapability(model.name), - supportsThinking: modelInfos[i].supportsThinking ?? false, - thinkingLevelsOnly: modelInfos[i].thinkingLevelsOnly, - acceptsThinkingKwarg: modelInfos[i].acceptsThinkingKwarg ?? false, - maxContextLength: modelInfos[i].contextLength, - }, - details: model.details, - lastUpdated: new Date().toISOString(), - }), - ); + return await mapOllamaModels(generativeModels, probeBase, server); } } } catch (error) { + // A refusal - including the keyed-HTTP explanation - is a failed discovery, so the saved + // model list stays in place instead of being replaced by whatever /api/tags returns. + if (error instanceof RemoteAuthenticationError) { + // A capability probe refused on private HTTP never carried the key either. + if (keyedHttpEndpoint(server.endpoint, server.apiKey)) throw new Error(HTTP_API_KEY_ERROR); + throw error; + } + if (error instanceof Error && error.message === HTTP_API_KEY_ERROR) throw error; logger.warn('[RemoteServer] Failed to fetch from /v1/models:', error); } @@ -482,47 +514,25 @@ export async function fetchModelsFromServer( headers, }); + if (response.status === 401 || response.status === 403) { + throw new RemoteAuthenticationError(response.status); + } if (response.ok) { const data = await response.json(); if (Array.isArray(data.models)) { - const nameDetect = { - vision: detectVisionCapability, - toolCalling: detectToolCallingCapability, - }; const generativeModels = data.models.filter((model: { name: string }) => isGenerativeModel(model.name), ); - const modelInfos = await Promise.all( - generativeModels.map((model: { name: string }) => - fetchModelCapabilities(url, model.name, nameDetect), - ), - ); - return generativeModels.map( - ( - model: { name: string; details?: Record }, - i: number, - ) => ({ - id: model.name, - name: displayModelName(model.name), - serverId: server.id, - capabilities: { - supportsVision: modelInfos[i].supportsVision, - supportsToolCalling: - modelInfos[i].supportsToolCalling ?? - detectToolCallingCapability(model.name), - supportsThinking: modelInfos[i].supportsThinking ?? false, - thinkingLevelsOnly: modelInfos[i].thinkingLevelsOnly, - acceptsThinkingKwarg: modelInfos[i].acceptsThinkingKwarg ?? false, - maxContextLength: modelInfos[i].contextLength, - }, - details: model.details, - lastUpdated: new Date().toISOString(), - }), - ); + return await mapOllamaModels(generativeModels, probeBase, server); } } } catch (error) { + if (error instanceof RemoteAuthenticationError) { + // On private HTTP the key is never sent, so the refusal is explained by the HTTPS rule. + if (keyedHttpEndpoint(server.endpoint, server.apiKey)) throw new Error(HTTP_API_KEY_ERROR); + throw error; + } logger.warn('[RemoteServer] Failed to fetch from /api/tags:', error); }