From 73b1b890bb43807965019eb5a0d462d6b1613aca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:09:53 +0000 Subject: [PATCH 01/21] fix(remote): send the saved key with every connection and capability check (item 1) - Startup discovery and the capability refresh on model selection read the saved key from Keychain (after the existing migration) and pass it on. - llama.cpp /props, Ollama /api/show, LM Studio /api/v1/models and the LM Studio thinking probe send the key through the existing HTTPS-only header policy and refuse redirects. - A 401/403 is an authentication failure: model-list requests throw it, the connection check returns it before any health-page fallback, and a refused probe is not read as proof the model lacks a feature. Discovery failure keeps the last known models. - Authenticated /props requests bypass the per-endpoint in-flight cache, so servers with different keys never share a result. - A keyed private HTTP server reports that keys are only sent over HTTPS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/remoteServerManagerUtils.ts | 6 +- src/services/remoteTransportPolicy.ts | 9 +++ src/stores/remoteModelCapabilities.ts | 87 ++++++++++++++++++++---- 3 files changed, 85 insertions(+), 17 deletions(-) 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/stores/remoteModelCapabilities.ts b/src/stores/remoteModelCapabilities.ts index 058a57747..20d9862c8 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' }], @@ -284,20 +320,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 +361,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 +475,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 { From 375da8f0f2c0b9d2b36c7f8d5055b4bbdcc80adb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:10:14 +0000 Subject: [PATCH 02/21] fix(remote): report a refused key instead of an empty model list (item 1) Discovery passes the saved key to every capability probe and throws an authentication error on a 401/403 model-list response instead of returning no models. The connection check returns the refusal before falling back to a health page, and a keyed private HTTP address reports that keys are only sent over HTTPS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/httpClientUtils.ts | 15 +++++++++++++++ src/stores/remoteServerHelpers.ts | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/services/httpClientUtils.ts b/src/services/httpClientUtils.ts index b6ba67693..de6ca1ccd 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,10 @@ export async function testEndpoint( let url = endpoint; while (url.endsWith('/')) url = url.slice(0, -1); + if (keyedHttpEndpoint(url, apiKey)) { + return { success: false, error: HTTP_API_KEY_ERROR }; + } + const authHeaders: Record = { Accept: 'application/json', ...remoteAuthorizationHeaders(url, apiKey), @@ -157,6 +163,15 @@ 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, + 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/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index f94a490d1..3716c1b1c 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'; @@ -349,6 +352,7 @@ export async function fetchModelsFromServer( ): Promise { const url = trimTrailingSlashes(server.endpoint); const isOpenRouter = new URL(url).hostname === 'openrouter.ai'; + if (keyedHttpEndpoint(server.endpoint, server.apiKey)) throw new Error(HTTP_API_KEY_ERROR); // Headers for authentication const headers: Record = { @@ -366,6 +370,10 @@ export async function fetchModelsFromServer( headers, }); + // A refused key is an authentication failure, not "this server has no models". + if (response.status === 401 || response.status === 403) { + throw new RemoteAuthenticationError(response.status); + } if (response.ok) { const data = await response.json(); @@ -395,7 +403,7 @@ export async function fetchModelsFromServer( supportsThinking: !!model.reasoning, thinkingLevelsOnly: model.reasoning?.mandatory === true, } - : fetchModelCapabilities(url, model.id, nameDetect), + : fetchModelCapabilities(url, model.id, nameDetect, server.apiKey), ), ); return generativeModels.map( @@ -443,7 +451,7 @@ export async function fetchModelsFromServer( ); const modelInfos = await Promise.all( generativeModels.map((model: { name: string }) => - fetchModelCapabilities(url, model.name, nameDetect), + fetchModelCapabilities(url, model.name, nameDetect, server.apiKey), ), ); return generativeModels.map( @@ -471,6 +479,7 @@ export async function fetchModelsFromServer( } } } catch (error) { + if (error instanceof RemoteAuthenticationError) throw error; logger.warn('[RemoteServer] Failed to fetch from /v1/models:', error); } @@ -482,6 +491,9 @@ 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(); @@ -495,7 +507,7 @@ export async function fetchModelsFromServer( ); const modelInfos = await Promise.all( generativeModels.map((model: { name: string }) => - fetchModelCapabilities(url, model.name, nameDetect), + fetchModelCapabilities(url, model.name, nameDetect, server.apiKey), ), ); return generativeModels.map( @@ -523,6 +535,7 @@ export async function fetchModelsFromServer( } } } catch (error) { + if (error instanceof RemoteAuthenticationError) throw error; logger.warn('[RemoteServer] Failed to fetch from /api/tags:', error); } From 16479c4d7ef58068be53c242287741f271cf42b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:10:14 +0000 Subject: [PATCH 03/21] fix(remote): an address saved with /v1 checks and probes the right paths (item 1) Separate defect found while tracing #696. The connection check requested /v1/v1/models for an address ending in /v1, got a 404 and fell back to a health page; the capability probes requested /v1/props and /v1/api/show, got 404s and fell back to name guessing, which hid tool support. The check now uses the same /v1 rule the model-list request already used, and the probes run from the address without its trailing /v1. Proxy prefixes are kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/httpClientUtils.ts | 2 +- src/stores/remoteServerHelpers.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/services/httpClientUtils.ts b/src/services/httpClientUtils.ts index de6ca1ccd..43ead4c44 100644 --- a/src/services/httpClientUtils.ts +++ b/src/services/httpClientUtils.ts @@ -155,7 +155,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, diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 3716c1b1c..34065c4c0 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -353,6 +353,9 @@ export async function fetchModelsFromServer( const url = trimTrailingSlashes(server.endpoint); const isOpenRouter = new URL(url).hostname === 'openrouter.ai'; if (keyedHttpEndpoint(server.endpoint, server.apiKey)) throw new Error(HTTP_API_KEY_ERROR); + // 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 = { @@ -403,7 +406,7 @@ export async function fetchModelsFromServer( supportsThinking: !!model.reasoning, thinkingLevelsOnly: model.reasoning?.mandatory === true, } - : fetchModelCapabilities(url, model.id, nameDetect, server.apiKey), + : fetchModelCapabilities(probeBase, model.id, nameDetect, server.apiKey), ), ); return generativeModels.map( @@ -451,7 +454,7 @@ export async function fetchModelsFromServer( ); const modelInfos = await Promise.all( generativeModels.map((model: { name: string }) => - fetchModelCapabilities(url, model.name, nameDetect, server.apiKey), + fetchModelCapabilities(probeBase, model.name, nameDetect, server.apiKey), ), ); return generativeModels.map( @@ -507,7 +510,7 @@ export async function fetchModelsFromServer( ); const modelInfos = await Promise.all( generativeModels.map((model: { name: string }) => - fetchModelCapabilities(url, model.name, nameDetect, server.apiKey), + fetchModelCapabilities(probeBase, model.name, nameDetect, server.apiKey), ), ); return generativeModels.map( From eddb566d7192797200f141a8ef88aa2f4a82272e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:10:29 +0000 Subject: [PATCH 04/21] fix(remote): the server list checks each server with its saved key (item 1) Opening the server list and the per-row check called the store directly without the key, so an authenticated server read as offline. Both now go through the manager's connection check, which reads the key from Keychain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/screens/RemoteServersScreen.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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); From fe810da7df8f8d7529b033f79c3cf48eec1f1687 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:11:53 +0000 Subject: [PATCH 05/21] fix(images): remote JPEG and WebP images can be deleted (item 3) Remote generation saves .jpg or .webp when the server returns those formats, but delete only accepted .png and the native store only removes .png, so those images could never be deleted from the gallery. Delete now accepts the image's own file in generated_images with any of the formats it can be saved as; non-PNG files are removed after the same owned-path checks. The SM-X800 report has not been reproduced; this is the deletion defect found on the shared path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/localDreamGenerator.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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); From 9022536d51789a1ad089238ecf869dbf828bc64e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:12:53 +0000 Subject: [PATCH 06/21] fix(tools): a refused web search reports a failure, not empty results (item 2) The web search tool read the body of any response, so a rate-limited or refused search page parsed to zero results and the model was told nothing exists for the query. A non-OK response is now a tool error with its status. The 30 September report has not been reproduced; markup changes on the search page are still unchecked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/tools/handlers.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index b7dd6c36d..cb3e3b8ca 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.ok) { + throw new Error(`Web search is unavailable right now (HTTP ${response.status}).`); + } const html = await response.text(); const results = parseBraveResults(html); From 05c2e43df177fb385400c224124f4684657e8e78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:55:26 +0000 Subject: [PATCH 07/21] fix(remote): a refused LM Studio thinking probe is an authentication failure (item 1) A 401/403 from /v1/chat/completions during the thinking probe read as "this model does not think". It now propagates as an authentication error like the other probes; other failed probes still read as no thinking. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/stores/remoteModelCapabilities.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/stores/remoteModelCapabilities.ts b/src/stores/remoteModelCapabilities.ts index 20d9862c8..92f5c30b0 100644 --- a/src/stores/remoteModelCapabilities.ts +++ b/src/stores/remoteModelCapabilities.ts @@ -278,6 +278,7 @@ async function probeLmStudioThinking( }); 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 @@ -295,6 +296,7 @@ async function probeLmStudioThinking( 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); } From 0576bf4629cb773c1bed7db103919598a2738cb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:55:26 +0000 Subject: [PATCH 08/21] fix(remote): a failed key read marks the server's check as failed (item 1) When Keychain could not return the saved key, the connection check threw before running and the server list kept its earlier Connected status. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/remoteServerManager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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); } From 8964bc62ae152b0eb916f04ad265c5f61c05c991 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:58:25 +0000 Subject: [PATCH 09/21] fix(remote): keep keyless discovery on private HTTP; explain refusals there (item 1) Discovery on a private-LAN HTTP address with a saved key keeps working without the key, as before (a paired Desktop can be saved that way). Only when such a server refuses the request does the check say keys are only sent over HTTPS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/httpClientUtils.ts | 9 ++++----- src/stores/remoteServerHelpers.ts | 5 +++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/httpClientUtils.ts b/src/services/httpClientUtils.ts index 43ead4c44..2a266cd5e 100644 --- a/src/services/httpClientUtils.ts +++ b/src/services/httpClientUtils.ts @@ -142,10 +142,6 @@ export async function testEndpoint( let url = endpoint; while (url.endsWith('/')) url = url.slice(0, -1); - if (keyedHttpEndpoint(url, apiKey)) { - return { success: false, error: HTTP_API_KEY_ERROR }; - } - const authHeaders: Record = { Accept: 'application/json', ...remoteAuthorizationHeaders(url, apiKey), @@ -167,7 +163,10 @@ export async function testEndpoint( if (response.status === 401 || response.status === 403) { return { success: false, - error: `The server rejected the API key (HTTP ${response.status}). Check the key for this server.`, + // 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, }; } diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 34065c4c0..0c1804da6 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -352,7 +352,6 @@ export async function fetchModelsFromServer( ): Promise { const url = trimTrailingSlashes(server.endpoint); const isOpenRouter = new URL(url).hostname === 'openrouter.ai'; - if (keyedHttpEndpoint(server.endpoint, server.apiKey)) throw new Error(HTTP_API_KEY_ERROR); // 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; @@ -373,8 +372,10 @@ export async function fetchModelsFromServer( headers, }); - // A refused key is an authentication failure, not "this server has no models". + // 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) { From 8707dc713074f4e48a14d79843e4fd3f145b8197 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:58:49 +0000 Subject: [PATCH 10/21] fix(tools): treat HTTP 4xx/5xx search pages as failures by status (item 2) Checks the response status rather than ok, so the existing search fakes, which carry no ok field, still read as successful pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/tools/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index cb3e3b8ca..eca2389bf 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -66,7 +66,7 @@ async function handleWebSearch(query: string): Promise { }); // 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.ok) { + if (response.status >= 400) { throw new Error(`Web search is unavailable right now (HTTP ${response.status}).`); } const html = await response.text(); From 10076bd7a3a64ad8ed3cd9e2669de1e102ebdb5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:43:25 +0000 Subject: [PATCH 11/21] test(remote): keep two remote-model journeys deterministic on a loaded CI runner Both timed out in CI (41-minute Jest step) while passing on the same commit earlier and in under 2 s locally. The failure-copy suite now answers 'unreachable' at the Desktop HTTP boundary from the start of each test, so no real LAN address is ever dialled, and both suites give their rendered waits headroom for a slow runner. Assertions are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- ...elFailureCopy.rendered.integration.test.tsx | 18 +++++++++++++----- ...eReasoningDropped.rendered.redflow.test.tsx | 10 +++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) 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); }); From 354f7a94a7a647bce835b5d635752c7ed4fb5545 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:47:11 +0000 Subject: [PATCH 12/21] fix(remote): keep saved models when a keyed HTTP server refuses discovery (audit 1, item 1) The HTTPS-key explanation was a plain Error, so the /v1/models catch swallowed it, tried /api/tags, and an empty answer replaced the saved model list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/stores/remoteServerHelpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 0c1804da6..67e774ce4 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -483,7 +483,10 @@ export async function fetchModelsFromServer( } } } 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) throw error; + if (error instanceof Error && error.message === HTTP_API_KEY_ERROR) throw error; logger.warn('[RemoteServer] Failed to fetch from /v1/models:', error); } From e757604447fdb58c76669d5ffa1c094eaa948062 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:57:12 +0000 Subject: [PATCH 13/21] refactor(remote): one mapping for the two Ollama-shaped discovery paths /v1/models (Ollama format) and /api/tags built the same RemoteModel fields with the same keyed capability probe. Both now call mapOllamaModels; each keeps its own model filter, and key handling, errors, fallback order and the OpenAI/OpenRouter mapping are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/stores/remoteServerHelpers.ts | 98 +++++++++++++------------------ 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 67e774ce4..244b68d59 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -347,6 +347,44 @@ 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 { @@ -453,33 +491,7 @@ 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(probeBase, model.name, nameDetect, server.apiKey), - ), - ); - 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 mapOllamaModels(generativeModels, probeBase, server); } } } catch (error) { @@ -505,40 +517,10 @@ export async function fetchModelsFromServer( 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(probeBase, model.name, nameDetect, server.apiKey), - ), - ); - 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 mapOllamaModels(generativeModels, probeBase, server); } } } catch (error) { From 7980637f1fe242186c7d1ed33ede6dcb8d2a49a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:37:40 +0000 Subject: [PATCH 14/21] fix(remote): explain a withheld key on every discovery refusal A 401/403 from /api/tags or a capability probe on a keyed private HTTP server now reports the HTTPS-only rule, as /v1/models already did, instead of saying the server rejected a key that was never sent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/stores/remoteServerHelpers.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 244b68d59..6a38f4c92 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -497,7 +497,11 @@ export async function fetchModelsFromServer( } 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) throw error; + 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); } @@ -524,7 +528,11 @@ export async function fetchModelsFromServer( } } } catch (error) { - if (error instanceof RemoteAuthenticationError) throw 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); } From 33c86e0e7090135d0a4c9245545721ee1cb14ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:44:53 +0000 Subject: [PATCH 15/21] fix(remote): await Ollama mapping inside its discovery try Returning the mapping promise unawaited let a capability probe's refusal skip the catch, so a keyed HTTP server still showed the wrong key error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/stores/remoteServerHelpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/remoteServerHelpers.ts b/src/stores/remoteServerHelpers.ts index 6a38f4c92..2c30cf700 100644 --- a/src/stores/remoteServerHelpers.ts +++ b/src/stores/remoteServerHelpers.ts @@ -491,7 +491,7 @@ export async function fetchModelsFromServer( const generativeModels = data.models.filter( (model: { name: string; kind?: unknown }) => isTextModel(model), ); - return mapOllamaModels(generativeModels, probeBase, server); + return await mapOllamaModels(generativeModels, probeBase, server); } } } catch (error) { @@ -524,7 +524,7 @@ export async function fetchModelsFromServer( const generativeModels = data.models.filter((model: { name: string }) => isGenerativeModel(model.name), ); - return mapOllamaModels(generativeModels, probeBase, server); + return await mapOllamaModels(generativeModels, probeBase, server); } } } catch (error) { From 08dda04fe3c49671aaef64dfbaf6fbce66171339 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:14:24 +0000 Subject: [PATCH 16/21] fix(images): deleting a chat removes its remote JPEG and WebP images (item 3) Chat deletion passed only the image id, so the delete looked for .png and left remote .jpg/.webp files on disk. Each call now passes the image's saved path, read before the records are removed, as the Gallery already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/screens/ChatScreen/useChatGenerationActions.ts | 4 +++- src/screens/ChatsListScreen.tsx | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index e53cbbd28..224c05416 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -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); 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); } From b38788d778d4f616ef59c85618f591b9b9a978cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:14:24 +0000 Subject: [PATCH 17/21] fix(remote): a keyed server on private HTTP fails its check with the HTTPS message (item 1) The key is never sent over HTTP, so a 200 from such a server showed Connected without using the saved key. The check now reports the HTTPS rule before any request. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/services/httpClientUtils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/services/httpClientUtils.ts b/src/services/httpClientUtils.ts index 2a266cd5e..c1c4a4a15 100644 --- a/src/services/httpClientUtils.ts +++ b/src/services/httpClientUtils.ts @@ -142,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), From e88510563ce72e0d1f99cdbfb115df85f9d15c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:23:47 +0000 Subject: [PATCH 18/21] chore(chat): mark three fire-and-forget generation calls with void SonarCloud's reliability gate flags these unawaited promises in a file this PR changes. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- src/screens/ChatScreen/useChatGenerationActions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 224c05416..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); }, }); } @@ -1153,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'); From 17f739b3a2e9c9a28abe72c2c8aeedcdffc15143 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:46:42 +0000 Subject: [PATCH 19/21] test(images): deleting a chat removes its remote JPEG and WebP files (item 3) The real chats screen and stores delete a chat whose images were saved as .jpg and .webp; the in-memory device filesystem shows the files gone and another chat's image kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- .../chatDeleteRemoteImages.rendered.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 __tests__/integration/chats/chatDeleteRemoteImages.rendered.test.tsx 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))); + }); +}); From bd822af299f7960606ed2c24779093bbaf4cfec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:47:58 +0000 Subject: [PATCH 20/21] test(remote): a saved key on private HTTP is explained on every check (item 1) The real endpoint check and discovery against a LAN server that lists models openly and refuses everything else: the check fails with the HTTPS message without contacting the server, a keyless check still connects, and a refused capability check reports the HTTPS rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- .../remote/keyedHttpServer.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 __tests__/integration/remote/keyedHttpServer.test.ts 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); + }); +}); From d5b6f4e0dfb4ea98f10f0dd9811a8036f4fc6781 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:04:50 +0000 Subject: [PATCH 21/21] test(remote): a saved key works across check, models, restart and pick; HTTP explains itself (items 1a/1b) The real manager and stores against a llama.cpp server that requires its key, with the Keychain and app storage persisting across a restart: the key reaches the check, discovery and capability probes, a wrong key reads as refused and keeps known models, and a keyed /v1 HTTP address explains the HTTPS rule while a keyless one still connects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NLGbSsujqjtnxBLLcnb9Ph --- .../integration/remote/remoteSavedKey.test.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 __tests__/integration/remote/remoteSavedKey.test.ts 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 }); + }); +});