Skip to content

Commit 24fa644

Browse files
jfr956claude
andcommitted
fix(models): keep model switches visible and compare normalized server URLs
Mobile shows the enabled-model switches even when the default is a typed id. Both settings screens compare the saved and typed server URL with the same normalizer the server uses, so a trailing slash no longer clears the ticks. The normalizer moves to contracts and adapters re-exports it. Refs elie222#1370 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6214376 commit 24fa644

7 files changed

Lines changed: 93 additions & 42 deletions

File tree

‎apps/mobile/app/(settings)/models.tsx‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,8 @@ export default function Models() {
937937
/>
938938
{feedbackAnchor === "probe" ? feedback : null}
939939
<Text style={[styles.sectionTitle, { marginTop: 12 }]}>{t("Model")}</Text>
940-
{compatibleRows.includes(modelId) ? (
940+
{/* The switches stay visible while the default is "Other model…" or a typed id. */}
941+
{compatibleRows.length ? (
941942
<View style={styles.card}>
942943
{compatibleRows.map((entry) => (
943944
// The switch sits beside the radio, not inside it: an accessible
@@ -967,21 +968,24 @@ export default function Models() {
967968
/>
968969
</View>
969970
))}
970-
<Pressable
971-
accessibilityRole="radio"
972-
accessibilityState={{ selected: false }}
973-
disabled={probing}
974-
onPress={() => stageCompatibleModelId("")}
975-
style={({ pressed }) => [
976-
styles.modelRow,
977-
probing && styles.disabled,
978-
pressed && styles.pressed,
979-
]}
980-
>
981-
<Text style={styles.modelLabel}>{t("Other model…")}</Text>
982-
</Pressable>
971+
{compatibleRows.includes(modelId) ? (
972+
<Pressable
973+
accessibilityRole="radio"
974+
accessibilityState={{ selected: false }}
975+
disabled={probing}
976+
onPress={() => stageCompatibleModelId("")}
977+
style={({ pressed }) => [
978+
styles.modelRow,
979+
probing && styles.disabled,
980+
pressed && styles.pressed,
981+
]}
982+
>
983+
<Text style={styles.modelLabel}>{t("Other model…")}</Text>
984+
</Pressable>
985+
) : null}
983986
</View>
984-
) : (
987+
) : null}
988+
{compatibleRows.includes(modelId) ? null : (
985989
<>
986990
<TextInput
987991
accessibilityLabel={t("Model id")}

‎apps/web/src/pages/ModelSettingsOverlay.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @vitest-environment jsdom
22

3+
import type * as UiWeb from "@rakazo/ui-web";
34
import type { ComponentProps, ReactNode } from "react";
45
import { act } from "react";
56
import { createRoot } from "react-dom/client";
@@ -46,8 +47,7 @@ vi.mock("@lingui/react/macro", () => {
4647
});
4748
vi.mock("@rakazo/ui-web", async () => {
4849
// The real Checkbox and Label so the row's accessible name is computed by Base UI.
49-
const { Checkbox, Label } =
50-
await vi.importActual<typeof import("@rakazo/ui-web")>("@rakazo/ui-web");
50+
const { Checkbox, Label } = await vi.importActual<typeof UiWeb>("@rakazo/ui-web");
5151
const Pass = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
5252
return {
5353
AlertDialog: ({ open, children }: { open?: boolean; children?: ReactNode }) =>

‎packages/adapters/src/openai-compatible-url.ts‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,15 @@
11
import { isIP } from "node:net";
2-
import { OPENAI_COMPATIBLE_PROVIDER_ID } from "@rakazo/contracts";
2+
import { normalizeOpenAiCompatibleBaseUrl, OPENAI_COMPATIBLE_PROVIDER_ID } from "@rakazo/contracts";
33
import { isCloudMetadataAddress, isLinkLocalAddress, isPrivateAddress } from "./network-address.js";
44

5-
export { OPENAI_COMPATIBLE_PROVIDER_ID };
5+
export { normalizeOpenAiCompatibleBaseUrl, OPENAI_COMPATIBLE_PROVIDER_ID };
66

77
const METADATA_HOSTS = new Set(["metadata.google.internal", "metadata.goog"]);
88

99
export function openAiCompatAllowPublicHosts(): boolean {
1010
return process.env.RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC === "1";
1111
}
1212

13-
export function normalizeOpenAiCompatibleBaseUrl(raw: string): string {
14-
const trimmed = raw.trim();
15-
let url: URL;
16-
try {
17-
url = new URL(trimmed);
18-
} catch {
19-
throw new Error("Base URL must be an absolute HTTP(S) URL");
20-
}
21-
if (url.protocol !== "http:" && url.protocol !== "https:") {
22-
throw new Error("Base URL must use http or https");
23-
}
24-
if (url.username || url.password) {
25-
throw new Error("Base URL must not contain credentials");
26-
}
27-
let path = url.pathname.replace(/\/+$/, "") || "";
28-
const VERSIONED_API_ROOT = /\/v\d+$/;
29-
if (!VERSIONED_API_ROOT.test(path)) {
30-
path = path ? `${path}/v1` : "/v1";
31-
}
32-
return `${url.origin}${path}`;
33-
}
34-
3513
function normalizeHostname(hostname: string): string {
3614
return hostname
3715
.toLowerCase()

‎packages/contracts/src/openai-compatible-ui.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
normalizeOpenAiCompatibleBaseUrl,
34
openAiCompatibleConnectReady,
45
openAiCompatibleProbeSuccessMessage,
56
} from "./openai-compatible-ui.js";
@@ -33,3 +34,18 @@ describe("openAiCompatibleConnectReady", () => {
3334
expect(openAiCompatibleProbeSuccessMessage(0)).toBe("Server found. Enter a model name.");
3435
});
3536
});
37+
38+
describe("normalizeOpenAiCompatibleBaseUrl", () => {
39+
it("drops trailing slashes and adds /v1 only when no version is present", () => {
40+
expect(normalizeOpenAiCompatibleBaseUrl(" http://127.0.0.1:8000/ ")).toBe(
41+
"http://127.0.0.1:8000/v1",
42+
);
43+
expect(normalizeOpenAiCompatibleBaseUrl("https://open.bigmodel.cn/api/paas/v4/")).toBe(
44+
"https://open.bigmodel.cn/api/paas/v4",
45+
);
46+
});
47+
48+
it("rejects text that is not an HTTP(S) URL", () => {
49+
expect(() => normalizeOpenAiCompatibleBaseUrl("127.0.0.1:8000")).toThrow();
50+
});
51+
});

‎packages/contracts/src/openai-compatible-ui.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,26 @@ export function openAiCompatibleProbeSuccessMessage(modelCount: number): string
1111
? `Found ${modelCount} model${modelCount === 1 ? "" : "s"}.`
1212
: "Server found. Enter a model name.";
1313
}
14+
15+
/** The canonical endpoint the server stores: trailing slashes dropped, `/v1` added when no `/vN`. */
16+
export function normalizeOpenAiCompatibleBaseUrl(raw: string): string {
17+
const trimmed = raw.trim();
18+
let url: URL;
19+
try {
20+
url = new URL(trimmed);
21+
} catch {
22+
throw new Error("Base URL must be an absolute HTTP(S) URL");
23+
}
24+
if (url.protocol !== "http:" && url.protocol !== "https:") {
25+
throw new Error("Base URL must use http or https");
26+
}
27+
if (url.username || url.password) {
28+
throw new Error("Base URL must not contain credentials");
29+
}
30+
let path = url.pathname.replace(/\/+$/, "") || "";
31+
const VERSIONED_API_ROOT = /\/v\d+$/;
32+
if (!VERSIONED_API_ROOT.test(path)) {
33+
path = path ? `${path}/v1` : "/v1";
34+
}
35+
return `${url.origin}${path}`;
36+
}

‎packages/core/src/model-providers.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,21 @@ describe("openai-compatible model rows", () => {
333333
expect(savedCompatibleModelIds(undefined, saved.baseUrl)).toEqual([]);
334334
});
335335

336+
it("compares endpoints the way the server stores them", () => {
337+
expect(savedCompatibleModelIds(saved, "http://127.0.0.1:8000/v1/")).toEqual(["a", "b"]);
338+
expect(savedCompatibleModelIds(saved, "http://127.0.0.1:8000")).toEqual(["a", "b"]);
339+
expect(savedCompatibleModelIds(saved, "http://127.0.0.2:8000/v1")).toEqual([]);
340+
});
341+
342+
it("treats half-typed text as a different endpoint without throwing", () => {
343+
expect(savedCompatibleModelIds(saved, "http://")).toEqual([]);
344+
expect(savedCompatibleModelIds(saved, "127.0.0.1:80")).toEqual([]);
345+
expect(savedCompatibleModelIds({ ...saved, baseUrl: "not a url" }, " not a url ")).toEqual([
346+
"a",
347+
"b",
348+
]);
349+
});
350+
336351
it("lists saved, ticked and probed ids once each, in that order", () => {
337352
expect(compatibleModelRows(["a", "b"], ["b", "c"], ["d", "a"])).toEqual(["a", "b", "c", "d"]);
338353
});

‎packages/core/src/model-providers.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ModelCatalogEntry } from "@rakazo/contracts";
2+
import { normalizeOpenAiCompatibleBaseUrl } from "@rakazo/contracts";
23
import { matchesSearchQuery } from "./search.js";
34

45
/** Effort choices an OpenAI-compatible endpoint stores when reasoning is enabled. */
@@ -280,12 +281,26 @@ export function filterModelCatalog(
280281

281282
type SavedCompatibleModels = { baseUrl?: string; enabledModelIds?: readonly string[] };
282283

284+
/** The server's endpoint form; half-typed text that is not a URL yet compares as trimmed text. */
285+
function comparableBaseUrl(baseUrl: string): string {
286+
try {
287+
return normalizeOpenAiCompatibleBaseUrl(baseUrl);
288+
} catch {
289+
return baseUrl.trim();
290+
}
291+
}
292+
283293
/** The saved enabled ids, but only while the form still points at the saved endpoint. */
284294
export function savedCompatibleModelIds(
285295
credential: SavedCompatibleModels | undefined,
286296
baseUrl: string,
287297
): string[] {
288-
if (!credential?.enabledModelIds || credential.baseUrl?.trim() !== baseUrl.trim()) return [];
298+
if (
299+
!credential?.enabledModelIds ||
300+
comparableBaseUrl(credential.baseUrl ?? "") !== comparableBaseUrl(baseUrl)
301+
) {
302+
return [];
303+
}
289304
return [...credential.enabledModelIds];
290305
}
291306

0 commit comments

Comments
 (0)