Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion gui/.eslint/i18n-allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ export function isTechnicalLiteral(value: string): boolean {
if (/^ocx\b/i.test(trimmed)) return true;
if (/^codex\b/i.test(trimmed)) return true;

// HTTP headers / auth schemes
// HTTP protocol / headers / auth schemes
if (/^HTTP$/i.test(trimmed)) return true;
if (/^Authorization\b/i.test(trimmed)) return true;
if (/^Bearer\b/i.test(trimmed)) return true;
if (/^Content-Type\b/i.test(trimmed)) return true;
Expand Down
22 changes: 15 additions & 7 deletions gui/src/components/provider-workspace/ProviderSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useT } from "../../i18n/shared";
import { IconLock } from "../../icons";
import { isCatalogProviderId } from "../../provider-icons";
import { openAiAccountProviderState } from "../../provider-payload";
import { providerSupportsLiveModelDiscovery } from "../../provider-workspace/catalog";
import type { CatalogPreset } from "../provider-catalog/provider-presets";
import { authModeLabel } from "./ProviderRail";
import type { WorkspaceItem, ProviderUpdatePatch } from "./types";
Expand All @@ -37,14 +38,16 @@ export default function ProviderSettings({
}) {
const t = useT();
const initialAuth = String(item.authMode ?? (item.keyOptional ? "local" : "key"));
const liveModelDiscoverySupported = providerSupportsLiveModelDiscovery(item.name, item);
const savedLiveModels = liveModelDiscoverySupported ? item.liveModels !== false : false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const [adapter, setAdapter] = useState(item.adapter);
const [baseUrl, setBaseUrl] = useState(item.baseUrl);
const [defaultModel, setDefaultModel] = useState(item.defaultModel ?? "");
const [authMode, setAuthMode] = useState(initialAuth);
const [apiKeyTransport, setApiKeyTransport] = useState(item.apiKeyTransport ?? "x-api-key");
const [note, setNote] = useState(item.note ?? "");
const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false);
const [liveModels, setLiveModels] = useState(item.liveModels !== false);
const [liveModels, setLiveModels] = useState(savedLiveModels);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [accountMode, setAccountMode] = useState<"pool" | "direct">(item.codexAccountMode ?? "pool");
Expand All @@ -63,11 +66,11 @@ export default function ProviderSettings({
setApiKeyTransport(item.apiKeyTransport ?? "x-api-key");
setNote(item.note ?? "");
setAllowPrivateNetwork(item.allowPrivateNetwork ?? false);
setLiveModels(item.liveModels !== false);
setLiveModels(savedLiveModels);
setMsg(null);
setModeMsg(null);
queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)));
}, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]);
}, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, baseUrlChoices]);
/* eslint-enable react-hooks/set-state-in-effect */

// Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress
Expand Down Expand Up @@ -116,7 +119,7 @@ export default function ProviderSettings({
|| (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key"))
|| note.trim() !== (item.note ?? "")
|| allowPrivateNetwork !== (item.allowPrivateNetwork ?? false)
|| liveModels !== (item.liveModels !== false);
|| liveModels !== savedLiveModels;

useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]);

Expand Down Expand Up @@ -155,7 +158,7 @@ export default function ProviderSettings({
const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork };
// Keep omitted legacy values omitted unless the user actually changes this toggle.
// Otherwise an unrelated settings save manufactures `liveModels: true` provenance.
if (liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels;
if (liveModelDiscoverySupported && liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels;
if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport;
else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = "";
const res = await onUpdateProvider(item.name, patch);
Expand Down Expand Up @@ -200,7 +203,7 @@ export default function ProviderSettings({
setAdapter(item.adapter); setBaseUrl(item.baseUrl);
setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth);
setApiKeyTransport(item.apiKeyTransport ?? "x-api-key");
setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null);
setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); setMsg(null);
setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl));
};

Expand Down Expand Up @@ -335,7 +338,12 @@ export default function ProviderSettings({
<span className="pwi-settings-label">{t("pws.allowPrivateNetwork")}</span>
</label>
<label className="pwi-settings-field" style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
<input type="checkbox" checked={liveModels} onChange={e => setLiveModels(e.target.checked)} />
<input
type="checkbox"
checked={liveModels}
disabled={!liveModelDiscoverySupported}
onChange={e => setLiveModels(e.target.checked)}
/>
<span>
<span className="pwi-settings-label">{t("pws.liveModels")}</span>
<span className="muted text-label" style={{ display: "block", marginTop: 2 }}>{t("pws.liveModelsDesc")}</span>
Expand Down
17 changes: 17 additions & 0 deletions gui/src/provider-workspace/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ function normalizedBaseUrl(value: string): string | undefined {
}
}

const CANONICAL_PROVIDER_PROTOCOL = new URL(CODEX_FORWARD_BASE_URL).protocol;
function providerEndpoint(host: string, ...path: string[]): string {
return `${CANONICAL_PROVIDER_PROTOCOL}//${host}/${path.join("/")}`;
}

const STATIC_MODEL_CATALOG_TRANSPORTS: Readonly<Record<string, { adapter: string; baseUrl: string }>> = {
"cline-pass": { adapter: "openai-chat", baseUrl: providerEndpoint("api.cline.bot", "api", "v1") },
"mimo-free": { adapter: "mimo-free", baseUrl: providerEndpoint("api.xiaomimimo.com", "api", "free-ai", "openai", "chat") },
};

/** Keep the Providers toggle aligned with the backend's canonical static-catalog boundary. */
export function providerSupportsLiveModelDiscovery(name: string, provider: WorkspaceProvider): boolean {
const canonical = STATIC_MODEL_CATALOG_TRANSPORTS[name];
if (!canonical || provider.adapter !== canonical.adapter) return true;
return normalizedBaseUrl(provider.baseUrl) !== normalizedBaseUrl(canonical.baseUrl);
}

/** Loopback host check shared with the provider-kind classifier (WP080a). */
export function hasLoopbackBaseUrl(baseUrl: string): boolean {
try {
Expand Down
43 changes: 43 additions & 0 deletions gui/tests/provider-settings-live-models-provenance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,46 @@ test("changing an explicit false to true sends an explicit liveModels choice", a
expect(patches[0]?.liveModels).toBe(true);
await act(async () => { root.unmount(); });
});

test("canonical ClinePass shows the static catalog as disabled even with stale liveModels true", async () => {
const { root, container } = await mountSettings({
name: "cline-pass",
adapter: "openai-chat",
baseUrl: "https://api.cline.bot/api/v1",
authMode: "key",
liveModels: true,
} as WorkspaceItem);
const toggles = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');

expect(toggles[1]?.disabled).toBe(true);
expect(toggles[1]?.checked).toBe(false);
await act(async () => { root.unmount(); });
});

test("same-named custom MiMo provider keeps live discovery editable", async () => {
const { root, container } = await mountSettings({
name: "mimo-free",
adapter: "openai-chat",
baseUrl: "https://example.test/v1",
authMode: "key",
liveModels: true,
} as WorkspaceItem);
const toggles = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');

expect(toggles[1]?.disabled).toBe(false);
expect(toggles[1]?.checked).toBe(true);
await act(async () => { root.unmount(); });
});

test("key-optional auth fallback remains local for unrelated providers", async () => {
const { root, container } = await mountSettings({
name: "custom-provider",
adapter: "openai-chat",
baseUrl: "https://example.test/v1",
keyOptional: true,
} as WorkspaceItem);
const selects = container.querySelectorAll<HTMLSelectElement>("select.input");

expect(selects[1]?.value).toBe("local");
await act(async () => { root.unmount(); });
});
17 changes: 17 additions & 0 deletions src/adapters/mimo-free.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ function randomUserAgent(): string {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
}

function isCanonicalMimoFreeEndpoint(baseUrl: string): boolean {
try {
const actual = new URL(baseUrl.trim());
const expected = new URL(MIMO_CHAT_URL);
actual.pathname = actual.pathname.replace(/\/+$/, "") || "/";
expected.pathname = expected.pathname.replace(/\/+$/, "") || "/";
return actual.toString().replace(/\/$/, "") === expected.toString().replace(/\/$/, "");
} catch {
return false;
}
}

/**
* Anonymous per-install client id for the bootstrap `client` field. A random UUID
* persisted under the config dir (OPENCODEX_HOME-aware) — deliberately NOT derived
Expand Down Expand Up @@ -194,6 +206,11 @@ export function injectMimoSystemMarker(body: unknown): unknown {
* On 401/403, flushes the JWT cache and retries once via fetchResponse.
*/
export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdapter {
if (!isCanonicalMimoFreeEndpoint(provider.baseUrl)) {
throw new Error(
"The mimo-free adapter only supports the canonical Xiaomi MiMo Free endpoint. Use openai-chat for a custom endpoint.",
);
}
const base = createOpenAIChatAdapter(provider);
// Per-adapter session-affinity id (random, per process instance).
const sessionId = `ses_${Math.random().toString(36).slice(2, 26)}`;
Expand Down
15 changes: 11 additions & 4 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { CodexAccountMode, OcxProviderConfig } from "../types";
import {
PROVIDER_REGISTRY,
providerMatchesRegistryTransport,
registryEntryForProviderDestination,
type ProviderRegistryEntry,
} from "./registry";
import {
providerMatchesRegistryTransportWithStaticGuards,
registryEntrySupportsLiveModelDiscovery,
repairStaticModelCatalogProvider,
} from "./static-model-discovery";

export interface DerivedKeyLoginProvider {
label: string;
Expand Down Expand Up @@ -204,6 +208,7 @@ export function applyDirectReasoningEffortContracts(
* keep distinguishing local runtimes from API-key providers after the seed round-trip.
*/
export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderConfig {
const liveModels = registryEntrySupportsLiveModelDiscovery(entry) ? entry.liveModels : false;
return {
adapter: entry.adapter,
baseUrl: entry.baseUrl,
Expand All @@ -219,7 +224,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}),
...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
...(entry.models ? { models: [...entry.models] } : {}),
...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
...(liveModels !== undefined ? { liveModels } : {}),
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}),
...(entry.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(entry.modelInputModalities) } : {}),
Expand Down Expand Up @@ -263,6 +268,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
for (const entry of PROVIDER_REGISTRY) {
if (entry.authKind !== "key") continue;
if (!entry.dashboardUrl) throw new Error(`Registry key provider missing dashboardUrl: ${entry.id}`);
const liveModels = registryEntrySupportsLiveModelDiscovery(entry) ? entry.liveModels : false;
out[entry.id] = {
label: entry.label,
baseUrl: entry.baseUrl,
Expand All @@ -272,7 +278,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
dashboardUrl: entry.dashboardUrl,
...(entry.models ? { models: [...entry.models] } : {}),
...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
...(liveModels !== undefined ? { liveModels } : {}),
...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}),
Expand Down Expand Up @@ -378,7 +384,7 @@ function enrichReasoningSummariesByDestination(prov: OcxProviderConfig): void {

export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void {
const entry = PROVIDER_REGISTRY.find(row => row.id === name);
if (!entry || !providerMatchesRegistryTransport(name, prov)) {
if (!entry || !providerMatchesRegistryTransportWithStaticGuards(name, prov)) {
// Name lookup failed, but the row may still point at a vendor route we know. #1100 was
// reported against a hand-added provider literally named "GLM": routing worked, yet every
// piece of registry metadata was skipped because no registry id is called "GLM".
Expand All @@ -395,6 +401,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
modelReasoningEffortMap: prov.modelReasoningEffortMap,
};
const seed = providerConfigSeed(entry);
repairStaticModelCatalogProvider(name, prov);
if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport;
if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
Expand Down
6 changes: 4 additions & 2 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ const CLINE_PASS_MODELS = [
"cline-pass/mimo-v2.5",
"cline-pass/mimo-v2.5-pro",
"cline-pass/minimax-m3",
"cline-pass/qwen3.8-max",
"cline-pass/qwen3.7-max",
"cline-pass/qwen3.7-plus",
];
Expand All @@ -928,9 +929,10 @@ const CLINE_PASS_IMAGE_MODELS = new Set([
"cline-pass/minimax-m3",
"cline-pass/qwen3.7-plus",
]);
const CLINE_PASS_TEXT_ONLY_MODELS = CLINE_PASS_MODELS.filter(id => !CLINE_PASS_IMAGE_MODELS.has(id));
const CLINE_PASS_MODALITY_KNOWN_MODELS = CLINE_PASS_MODELS.filter(id => id !== "cline-pass/qwen3.8-max");
const CLINE_PASS_TEXT_ONLY_MODELS = CLINE_PASS_MODALITY_KNOWN_MODELS.filter(id => !CLINE_PASS_IMAGE_MODELS.has(id));
const CLINE_PASS_MODEL_INPUT_MODALITIES: Record<string, string[]> = Object.fromEntries(
CLINE_PASS_MODELS.map(id => [id, CLINE_PASS_IMAGE_MODELS.has(id) ? ["text", "image"] : ["text"]]),
CLINE_PASS_MODALITY_KNOWN_MODELS.map(id => [id, CLINE_PASS_IMAGE_MODELS.has(id) ? ["text", "image"] : ["text"]]),
);

export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
Expand Down
86 changes: 86 additions & 0 deletions src/providers/static-model-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { OcxProviderConfig } from "../types";
import {
getProviderRegistryEntry,
providerMatchesRegistryTransport,
type ProviderRegistryEntry,
} from "./registry";

const STATIC_MODEL_CATALOG_PROVIDER_IDS = new Set(["cline-pass", "mimo-free"]);

function normalizedEndpoint(value: string): string {
const trimmed = value.trim();
try {
const parsed = new URL(trimmed);
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
return parsed.toString().replace(/\/$/, "");
} catch {
return trimmed.replace(/\/+$/, "");
}
}

function exactRegistryTransportMatch(
entry: ProviderRegistryEntry,
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
options: { allowLegacyMimoLocal?: boolean } = {},
): boolean {
if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false;
if (typeof provider.baseUrl !== "string" || provider.adapter !== entry.adapter) return false;
const legacyMimoLocal = options.allowLegacyMimoLocal === true
&& entry.id === "mimo-free"
&& provider.authMode === "local";
if (provider.authMode !== undefined && provider.authMode !== "key" && !legacyMimoLocal) return false;
return normalizedEndpoint(provider.baseUrl) === normalizedEndpoint(entry.baseUrl);
}

/** Registry policy for providers whose maintained model list is authoritative. */
export function registryEntrySupportsLiveModelDiscovery(entry: ProviderRegistryEntry): boolean {
return !STATIC_MODEL_CATALOG_PROVIDER_IDS.has(entry.id);
}

/**
* Static-catalog authority is tied to canonical provider identity and exact transport.
* Renamed/custom rows stay operator-owned: Cline and ClinePass intentionally share a transport,
* so destination matching alone cannot safely identify a renamed ClinePass configuration.
*/
export function staticModelCatalogEntryForProvider(
name: string,
provider: OcxProviderConfig,
): ProviderRegistryEntry | undefined {
const entry = getProviderRegistryEntry(name);
if (!entry || !STATIC_MODEL_CATALOG_PROVIDER_IDS.has(entry.id)) return undefined;
return exactRegistryTransportMatch(entry, provider, { allowLegacyMimoLocal: true })
? entry
: undefined;
}

export function providerSupportsLiveModelDiscovery(name: string, provider: OcxProviderConfig): boolean {
return staticModelCatalogEntryForProvider(name, provider) === undefined;
}

/**
* MiMo Free predates collision preservation in the registry. Keep same-named custom rows out of
* registry ownership without broadening the generic transport matcher to other key providers.
*/
export function providerMatchesRegistryTransportWithStaticGuards(
name: string,
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
): boolean {
if (name !== "mimo-free") return providerMatchesRegistryTransport(name, provider);
const entry = getProviderRegistryEntry(name);
return entry !== undefined
&& exactRegistryTransportMatch(entry, provider, { allowLegacyMimoLocal: true });
}

/** Repair only registry-owned legacy state; operator-owned model lists stay untouched. */
export function repairStaticModelCatalogProvider(name: string, provider: OcxProviderConfig): void {
const entry = staticModelCatalogEntryForProvider(name, provider);
if (!entry) return;
provider.liveModels = false;
if (
name === "mimo-free"
&& entry.id === "mimo-free"
&& (provider.authMode === undefined || provider.authMode === "local")
) {
provider.authMode = "key";
}
}
Loading
Loading