Skip to content

Commit e0af6d3

Browse files
authored
fix(assistant): make the Docker deployment AI proxy usable in the browser (#1453)
* fix(assistant): make the Docker deployment AI proxy usable in the browser A managed deployment injects a same-origin proxy path, but the panel always pinned the first saved profile, so the proxy was never reachable and the relative URL was sent to the SDK unresolved. Resolve the path against the page origin, expose the proxy as an explicit dropdown choice, and drop the Bearer header for that endpoint only, since the proxy holds the key itself. * Address CodeRabbit review feedback - Recognize a build-time managed proxy (VITE_GEOLIBRE_AI_URL baked into the bundle), not only one injected by the Docker entrypoint. Without this the panel still pinned the first saved profile and bypassed the proxy. Renamed hasDeploymentAssistantEnv to hasManagedAssistantProxy to match, and kept an endpoint the user typed into Settings out of the predicate. - Added coverage for the build-time-only, Docker-injected, and user-override cases.
1 parent d657ffb commit e0af6d3

6 files changed

Lines changed: 274 additions & 19 deletions

File tree

apps/geolibre-desktop/src/components/panels/AssistantPanel.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ import {
2626
import { useTranslation } from "react-i18next";
2727
import { AssistantSession } from "../../lib/assistant/agent";
2828
import { renderAssistantMarkdown } from "../../lib/assistant/markdown";
29+
import { selectActiveAssistantProfile } from "../../lib/assistant/profiles";
2930
import { openSettingsSection } from "../layout/SettingsDialog";
3031
import {
3132
ASSISTANT_PROVIDER_IDS,
3233
availableProviders,
3334
defaultModelFor,
35+
hasManagedAssistantProxy,
3436
hasProviderKey,
3537
PROVIDER_MODELS,
3638
PROVIDER_LABELS,
@@ -187,22 +189,19 @@ export function AssistantPanel({ mapControllerRef }: AssistantPanelProps) {
187189
return storeSettings.aiProfiles.some((p) => p.id === stored) ? stored : null;
188190
});
189191

192+
const deploymentProxyConfigured = hasManagedAssistantProxy();
193+
190194
// The currently active profile: if the user hasn't explicitly chosen one,
191195
// follow the default. Otherwise respect their explicit selection.
192196
const activeProfile: AssistantProfile | null = useMemo(() => {
193-
// If the user explicitly chose a profile via the dropdown, use that.
194-
if (userExplicitlyChoseProfile.current && selectedProfileId) {
195-
const found = aiProfiles.find((p) => p.id === selectedProfileId);
196-
if (found) return found;
197-
}
198-
// Fall back to the default profile.
199-
if (defaultAiProfileId) {
200-
const found = aiProfiles.find((p) => p.id === defaultAiProfileId);
201-
if (found) return found;
202-
}
203-
// Fall back to the first profile.
204-
return aiProfiles[0] ?? null;
205-
}, [selectedProfileId, aiProfiles, defaultAiProfileId]);
197+
return selectActiveAssistantProfile({
198+
profiles: aiProfiles,
199+
defaultProfileId: defaultAiProfileId,
200+
selectedProfileId,
201+
userExplicitlyChoseProfile: userExplicitlyChoseProfile.current,
202+
deploymentProxyConfigured,
203+
});
204+
}, [selectedProfileId, aiProfiles, defaultAiProfileId, deploymentProxyConfigured]);
206205

207206
// Queue of model-generated code snippets (run_python / run_maplibre_js)
208207
// awaiting the user's approval, each with the promise resolver its tool
@@ -597,6 +596,9 @@ export function AssistantPanel({ mapControllerRef }: AssistantPanelProps) {
597596
disabled={running}
598597
onChange={(event) => onProfileChange(event.target.value)}
599598
>
599+
{deploymentProxyConfigured ? (
600+
<option value="">{t("assistant.deploymentProxy")}</option>
601+
) : null}
600602
{aiProfiles.map((profile) => (
601603
<option key={profile.id} value={profile.id}>
602604
{profile.name}

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3009,6 +3009,7 @@
30093009
"toolError": "failed",
30103010
"provider": "LLM provider",
30113011
"profile": "AI profile",
3012+
"deploymentProxy": "Deployment proxy",
30123013
"model": "Model",
30133014
"codeApprovalTitle": "Run assistant code?",
30143015
"codeApprovalBody": "The assistant wants to run {{language}} code in the app. Review it first — it can change the map and access app data.",

apps/geolibre-desktop/src/lib/assistant/profiles.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@ function generateProfileId(): string {
1515
return `prof_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1616
}
1717

18+
export interface ActiveAssistantProfileOptions {
19+
profiles: AssistantProfile[];
20+
defaultProfileId: string | null;
21+
selectedProfileId: string | null;
22+
userExplicitlyChoseProfile: boolean;
23+
deploymentProxyConfigured: boolean;
24+
}
25+
26+
/**
27+
* Choose the assistant profile that should pin the current session.
28+
*
29+
* @param options Profile state and deployment-proxy availability.
30+
* @returns The active profile, or null to let provider auto-resolution choose.
31+
*/
32+
export function selectActiveAssistantProfile({
33+
profiles,
34+
defaultProfileId,
35+
selectedProfileId,
36+
userExplicitlyChoseProfile,
37+
deploymentProxyConfigured,
38+
}: ActiveAssistantProfileOptions): AssistantProfile | null {
39+
if (userExplicitlyChoseProfile) {
40+
if (!selectedProfileId) return null;
41+
const selected = profiles.find((profile) => profile.id === selectedProfileId);
42+
if (selected) return selected;
43+
}
44+
if (defaultProfileId) {
45+
const found = profiles.find((profile) => profile.id === defaultProfileId);
46+
if (found) return found;
47+
}
48+
if (deploymentProxyConfigured) return null;
49+
return profiles[0] ?? null;
50+
}
51+
1852
/**
1953
* Migrate a legacy flat `aiProviderEnv` map to an array of {@link AssistantProfile}.
2054
* The legacy format stored every env var key/value in one flat map; we split it

apps/geolibre-desktop/src/lib/assistant/provider.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export interface AssistantProviderConfig {
6363
apiKey?: string;
6464
/** OpenAI-compatible base URL (ollama, custom). */
6565
baseURL?: string;
66+
/** Suppress Bearer auth for same-origin proxies protected by browser auth. */
67+
suppressAuthorizationHeader?: boolean;
6668
/** AWS region (bedrock). */
6769
region?: string;
6870
/** AWS credentials (bedrock). */
@@ -259,17 +261,32 @@ export const PROVIDER_LABELS: Record<AssistantProviderId, string> = {
259261
*/
260262
export type RuntimeEnv = Record<string, string>;
261263

264+
function browserOrigin(): string | undefined {
265+
if (typeof window === "undefined") return undefined;
266+
const origin = window.location?.origin;
267+
return origin && origin !== "null" ? origin : undefined;
268+
}
269+
270+
function managedProxyBaseUrl(proxyUrl: string, baseOrigin?: string): string {
271+
let normalized = proxyUrl.trim().replace(/\/+$/, "");
272+
if (baseOrigin && normalized.startsWith("/")) {
273+
normalized = new URL(normalized, baseOrigin).toString().replace(/\/+$/, "");
274+
}
275+
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
276+
}
277+
262278
/** Public AI proxy configuration embedded by Vite for a managed build. */
263279
export function readBuildTimeAssistantEnv(
264280
viteEnv: Record<string, string | undefined> | undefined = (
265281
import.meta as ImportMeta & { env?: Record<string, string | undefined> }
266282
).env,
283+
baseOrigin?: string,
267284
): RuntimeEnv {
268285
if (!viteEnv) return {};
269286
const result: RuntimeEnv = {};
270287
const proxyUrl = viteEnv.VITE_GEOLIBRE_AI_URL?.trim().replace(/\/+$/, "");
271288
if (proxyUrl) {
272-
result.OPENAI_COMPATIBLE_BASE_URL = proxyUrl.endsWith("/v1") ? proxyUrl : `${proxyUrl}/v1`;
289+
result.OPENAI_COMPATIBLE_BASE_URL = managedProxyBaseUrl(proxyUrl, baseOrigin);
273290
result.OPENAI_COMPATIBLE_MODEL =
274291
viteEnv.VITE_GEOLIBRE_AI_MODEL?.trim() || result.OPENAI_COMPATIBLE_MODEL || "openai/gpt-5.5";
275292
}
@@ -284,12 +301,30 @@ export function readDeploymentAssistantEnv(): RuntimeEnv {
284301
__GEOLIBRE_DEPLOYMENT_ENV__?: Record<string, string | undefined>;
285302
}
286303
).__GEOLIBRE_DEPLOYMENT_ENV__;
287-
return readBuildTimeAssistantEnv(deploymentEnv);
304+
const result = readBuildTimeAssistantEnv(deploymentEnv, browserOrigin());
305+
if (result.OPENAI_COMPATIBLE_BASE_URL) {
306+
result.GEOLIBRE_AI_PROXY_BASE_URL = result.OPENAI_COMPATIBLE_BASE_URL;
307+
result.GEOLIBRE_AI_PROXY_OMIT_AUTHORIZATION = "1";
308+
}
309+
return result;
310+
}
311+
312+
/**
313+
* True when the build or the Docker entrypoint supplied a managed AI proxy.
314+
*
315+
* Deliberately ignores `__GEOLIBRE_RUNTIME_ENV__`: an endpoint the user typed
316+
* into Settings is their own custom provider, not an operator-managed proxy.
317+
*/
318+
export function hasManagedAssistantProxy(viteEnv?: Record<string, string | undefined>): boolean {
319+
return Boolean(
320+
readBuildTimeAssistantEnv(viteEnv, browserOrigin()).OPENAI_COMPATIBLE_BASE_URL ||
321+
readDeploymentAssistantEnv().OPENAI_COMPATIBLE_BASE_URL,
322+
);
288323
}
289324

290325
/** Read build-time credentials plus the live runtime environment map. */
291326
export function readRuntimeEnv(): RuntimeEnv {
292-
const built = readBuildTimeAssistantEnv();
327+
const built = readBuildTimeAssistantEnv(undefined, browserOrigin());
293328
if (typeof window === "undefined") return built;
294329
return {
295330
...built,
@@ -390,11 +425,17 @@ export function configForProvider(
390425
const baseURL = firstValue(env, "OPENAI_COMPATIBLE_BASE_URL");
391426
if (!baseURL || !modelId) return null;
392427
const apiKey = firstValue(env, "OPENAI_COMPATIBLE_API_KEY") ?? "not-needed";
428+
const normalizedBaseURL = baseURL.replace(/\/+$/, "");
429+
const proxyBaseURL = firstValue(env, "GEOLIBRE_AI_PROXY_BASE_URL")?.replace(/\/+$/, "");
393430
return {
394431
provider,
395432
apiKey,
396-
baseURL: baseURL.replace(/\/+$/, ""),
433+
baseURL: normalizedBaseURL,
397434
modelId,
435+
suppressAuthorizationHeader:
436+
env.GEOLIBRE_AI_PROXY_OMIT_AUTHORIZATION === "1" &&
437+
Boolean(proxyBaseURL) &&
438+
normalizedBaseURL === proxyBaseURL,
398439
};
399440
}
400441
case "bedrock": {
@@ -496,6 +537,7 @@ export async function createModel(config: AssistantProviderConfig): Promise<Mode
496537
modelId: config.modelId,
497538
clientConfig: {
498539
baseURL: config.baseURL,
540+
defaultHeaders: config.suppressAuthorizationHeader ? { Authorization: null } : undefined,
499541
dangerouslyAllowBrowser: true,
500542
},
501543
}) as unknown as Model;

tests/assistant-provider-persistence.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
33
import { normalizeDesktopSettings } from "../apps/geolibre-desktop/src/hooks/useDesktopSettings";
4+
import { selectActiveAssistantProfile } from "../apps/geolibre-desktop/src/lib/assistant/profiles";
45
import { mergeRuntimeEnv } from "../apps/geolibre-desktop/src/lib/assistant/provider";
6+
import type { AssistantProfile } from "../apps/geolibre-desktop/src/lib/assistant/provider";
57

68
const NO_SOURCES = {
79
osEnv: {},
@@ -130,6 +132,87 @@ describe("DesktopSettings.aiProfiles persistence and migration", () => {
130132
});
131133
});
132134

135+
describe("selectActiveAssistantProfile", () => {
136+
const profiles: AssistantProfile[] = [
137+
{
138+
id: "prof_openai",
139+
name: "OpenAI",
140+
provider: "openai",
141+
modelId: "gpt-5.6",
142+
fieldValues: { OPENAI_API_KEY: "sk-openai" },
143+
},
144+
{
145+
id: "prof_anthropic",
146+
name: "Anthropic",
147+
provider: "anthropic",
148+
modelId: "claude-opus-5",
149+
fieldValues: { ANTHROPIC_API_KEY: "sk-ant" },
150+
},
151+
];
152+
153+
it("falls back to the first profile when no deployment proxy is injected", () => {
154+
assert.equal(
155+
selectActiveAssistantProfile({
156+
profiles,
157+
defaultProfileId: null,
158+
selectedProfileId: null,
159+
userExplicitlyChoseProfile: false,
160+
deploymentProxyConfigured: false,
161+
})?.id,
162+
"prof_openai",
163+
);
164+
});
165+
166+
it("lets a Docker deployment proxy auto-resolve instead of pinning the first profile", () => {
167+
assert.equal(
168+
selectActiveAssistantProfile({
169+
profiles,
170+
defaultProfileId: null,
171+
selectedProfileId: null,
172+
userExplicitlyChoseProfile: false,
173+
deploymentProxyConfigured: true,
174+
}),
175+
null,
176+
);
177+
});
178+
179+
it("honors explicit and default profiles over the deployment proxy", () => {
180+
assert.equal(
181+
selectActiveAssistantProfile({
182+
profiles,
183+
defaultProfileId: "prof_anthropic",
184+
selectedProfileId: null,
185+
userExplicitlyChoseProfile: false,
186+
deploymentProxyConfigured: true,
187+
})?.id,
188+
"prof_anthropic",
189+
);
190+
assert.equal(
191+
selectActiveAssistantProfile({
192+
profiles,
193+
defaultProfileId: "prof_openai",
194+
selectedProfileId: "prof_anthropic",
195+
userExplicitlyChoseProfile: true,
196+
deploymentProxyConfigured: true,
197+
})?.id,
198+
"prof_anthropic",
199+
);
200+
});
201+
202+
it("lets an explicit empty selection use provider auto-resolution", () => {
203+
assert.equal(
204+
selectActiveAssistantProfile({
205+
profiles,
206+
defaultProfileId: "prof_openai",
207+
selectedProfileId: "",
208+
userExplicitlyChoseProfile: true,
209+
deploymentProxyConfigured: true,
210+
}),
211+
null,
212+
);
213+
});
214+
});
215+
133216
// The precedence order in mergeRuntimeEnv is the part most likely to regress
134217
// silently (swapping two spreads). Pin the guarantees the app relies on:
135218
// OS env < device AI keys < project Environment variables, with OS aliases

0 commit comments

Comments
 (0)