Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
21 changes: 21 additions & 0 deletions apps/controller/src/lib/model-provider-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ function resolveDefaultBaseUrls(
return getDefaultProviderBaseUrls(providerId);
}

function normalizeGoogleNativeBaseUrl(baseUrl: string): string {
return (
normalizeProviderBaseUrl(baseUrl)?.replace(/\/(v1|v1beta|v1alpha)$/i, "") ??
baseUrl
);
}

function isProviderProxied(input: {
providerId: string;
baseUrl: string;
Expand All @@ -111,6 +118,20 @@ function isProviderProxied(input: {
.filter((value): value is string => value !== null),
);

if (input.providerId === "google") {
const normalizedGoogleBaseUrl = normalizeGoogleNativeBaseUrl(input.baseUrl);
const normalizedGoogleDefaultBaseUrls = new Set(
[...normalizedDefaultBaseUrls].map((value) =>
normalizeGoogleNativeBaseUrl(value),
),
);

return (
normalizedGoogleDefaultBaseUrls.size > 0 &&
!normalizedGoogleDefaultBaseUrls.has(normalizedGoogleBaseUrl)
);
}

return (
normalizedDefaultBaseUrls.size > 0 &&
!normalizedDefaultBaseUrls.has(normalizedBaseUrl)
Expand Down
139 changes: 111 additions & 28 deletions apps/controller/src/services/model-provider-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,40 @@ function buildProviderUrl(
return `${normalizedBaseUrl}${normalizedPath}`;
}

function buildAnthropicModelDiscoveryUrls(
baseUrl: string | null | undefined,
): string[] {
if (!baseUrl || baseUrl.trim().length === 0) {
return [];
}

const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const candidateBaseUrls = [normalizedBaseUrl];

if (!/\/v\d+(?:alpha|beta)?$/i.test(normalizedBaseUrl)) {
candidateBaseUrls.push(`${normalizedBaseUrl}/v1`);
}

return [...new Set(candidateBaseUrls)]
.map((candidateBaseUrl) => buildProviderUrl(candidateBaseUrl, "/models"))
.filter((url): url is string => typeof url === "string" && url.length > 0);
}

function buildGoogleModelsUrl(
baseUrl: string | null | undefined,
): string | null {
if (!baseUrl || baseUrl.trim().length === 0) {
return null;
}

const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const versionedBaseUrl = /\/(v1|v1beta|v1alpha)$/i.test(normalizedBaseUrl)
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1beta`;
Comment thread
mrcfps marked this conversation as resolved.
Outdated

return buildProviderUrl(versionedBaseUrl, "/models");
}

function normalizeGoogleModelId(name: string | undefined): string {
if (typeof name !== "string") {
return "";
Expand Down Expand Up @@ -715,8 +749,21 @@ export class ModelProviderService {
const resolvedBaseUrl =
input.baseUrl ?? storedProvider?.baseUrl ?? defaultBaseUrl;

const verifyUrl = buildProviderUrl(resolvedBaseUrl, "/models") ?? "";
if (verifyUrl.length === 0) {
const verifyUrls =
runtimePolicy.apiKind === "google-generative-ai"
? [buildGoogleModelsUrl(resolvedBaseUrl)].filter(
(url): url is string => typeof url === "string" && url.length > 0,
)
: runtimePolicy.apiKind === "anthropic-messages"
? buildAnthropicModelDiscoveryUrls(resolvedBaseUrl)
: [buildProviderUrl(resolvedBaseUrl, "/models")].filter(
(url): url is string => typeof url === "string" && url.length > 0,
);
if (verifyUrls.length === 0) {
return { valid: false, error: "Unknown provider and no baseUrl given" };
}
const primaryVerifyUrl = verifyUrls[0];
if (!primaryVerifyUrl) {
return { valid: false, error: "Unknown provider and no baseUrl given" };
}

Expand All @@ -728,7 +775,7 @@ export class ModelProviderService {
}

const response = await proxyFetch(
buildProviderUrl(resolvedBaseUrl, "/api/tags") ?? verifyUrl,
buildProviderUrl(resolvedBaseUrl, "/api/tags") ?? primaryVerifyUrl,
{
headers: Object.keys(headers).length > 0 ? headers : undefined,
timeoutMs: 10000,
Expand Down Expand Up @@ -756,7 +803,7 @@ export class ModelProviderService {
}

if (runtimePolicy.apiKind === "google-generative-ai") {
const response = await proxyFetch(verifyUrl, {
const response = await proxyFetch(primaryVerifyUrl, {
headers: {
"x-goog-api-key": apiKey,
},
Expand Down Expand Up @@ -789,44 +836,80 @@ export class ModelProviderService {
}
: { Authorization: `Bearer ${apiKey}` };

const response = await proxyFetch(verifyUrl, {
headers,
timeoutMs: 10000,
});
if (!response.ok) {
if (providerId === "minimax" && response.status === 404) {
return { valid: true, models: MINI_MAX_API_MODELS };
let lastStatus: number | null = null;

for (const verifyUrl of verifyUrls) {
const response = await proxyFetch(verifyUrl, {
headers,
timeoutMs: 10000,
});
if (!response.ok) {
lastStatus = response.status;
if (
runtimePolicy.apiKind === "anthropic-messages" &&
response.status === 404
) {
continue;
}
if (providerId === "minimax" && response.status === 404) {
return { valid: true, models: MINI_MAX_API_MODELS };
}
if (providerId === "xiaomi" && response.status === 404) {
return {
valid: true,
models: getBundledProviderModelIds(providerId),
};
}
return { valid: false, error: `HTTP ${response.status}` };
}

let payload: { data?: Array<{ id: string }> };
try {
payload = (await response.json()) as {
data?: Array<{ id: string }>;
};
} catch {
if (runtimePolicy.apiKind === "anthropic-messages") {
continue;
}
throw new Error("Invalid JSON response");
}
if (providerId === "xiaomi" && response.status === 404) {

if (providerId === "xiaomi") {
return {
valid: true,
models: getBundledProviderModelIds(providerId),
models:
Array.isArray(payload.data) && payload.data.length > 0
? payload.data.map((item) => item.id)
: getBundledProviderModelIds(providerId),
};
}
return { valid: false, error: `HTTP ${response.status}` };
}

const payload = (await response.json()) as {
data?: Array<{ id: string }>;
};
if (providerId === "xiaomi") {
return {
valid: true,
models:
Array.isArray(payload.data) && payload.data.length > 0
? payload.data.map((item) => item.id)
: getBundledProviderModelIds(providerId),
: providerId === "minimax"
? MINI_MAX_API_MODELS
: [],
};
}

return {
valid: true,
models: Array.isArray(payload.data)
? payload.data.map((item) => item.id)
: providerId === "minimax"
? MINI_MAX_API_MODELS
: [],
};
if (providerId === "minimax" && lastStatus === 404) {
return { valid: true, models: MINI_MAX_API_MODELS };
}
if (providerId === "xiaomi" && lastStatus === 404) {
return {
valid: true,
models: getBundledProviderModelIds(providerId),
};
}
if (lastStatus !== null) {
return { valid: false, error: `HTTP ${lastStatus}` };
}

return { valid: false, error: "Invalid JSON response" };
} catch (error) {
return {
valid: false,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ async function runLaunchdColdStart(): Promise<void> {
new URL(runtimeConfig.urls.openclawBase).port || 18789,
),
nexuHome,
gatewayToken: isDev ? undefined : runtimeConfig.tokens.gateway,
gatewayToken: runtimeConfig.tokens.gateway,
webPort: runtimeConfig.ports.web,
webRoot,
plistDir: getDefaultPlistDir(isDev),
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/main/platforms/mac/launchd-residency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function createMacLaunchdBootstrapEnv(args: {
webRoot: runtimeRoots.webRoot,
plistDir: undefined,
nexuHome: runtimeRoots.nexuHome,
gatewayToken: app.isPackaged ? runtimeConfig.tokens.gateway : undefined,
gatewayToken: runtimeConfig.tokens.gateway,
openclawConfigPath: runtimeRoots.openclawConfigPath,
openclawStateDir: runtimeRoots.openclawStateDir,
webUrl: runtimeConfig.urls.web,
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/main/services/plist-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,12 @@ function generateOpenclawPlist(label: string, env: PlistEnv): string {
const errorPath = path.join(env.logDir, "openclaw.error.log");
const controllerLabel = SERVICE_LABELS.controller(env.isDev);

// In dev mode, use --auth none to simplify local development
const authArgs = env.isDev
? `
? env.gatewayToken
? `
<string>--auth</string>
<string>token</string>`
: `
<string>--auth</string>
<string>none</string>`
: "";
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/pages/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1322,12 +1322,19 @@ function AddCustomProviderDetail({
})
}
>
<SelectTrigger id="custom-provider-template" className="w-full">
<SelectTrigger
id="custom-provider-template"
className="w-full rounded-xl border-border bg-surface-0 text-text-primary shadow-none hover:bg-surface-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="rounded-2xl border-border bg-surface-0 text-text-primary shadow-[0_12px_32px_rgba(0,0,0,0.08)]">
{customTemplates.map((item) => (
<SelectItem key={item.id} value={item.id}>
<SelectItem
key={item.id}
value={item.id}
className="rounded-xl px-4 py-2 text-text-secondary focus:bg-surface-2 focus:text-text-primary data-[highlighted]:bg-surface-2 data-[highlighted]:text-text-primary data-[state=checked]:bg-surface-2 data-[state=checked]:text-text-primary"
>
{getCustomProviderTemplateLabel(
item.id as CustomProviderTemplateId,
t,
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/model-providers/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ const providerRegistryEntries = [
descriptionKey: "models.provider.google.description",
apiDocsUrl: "https://aistudio.google.com/app/apikey",
apiKeyPlaceholder: "AIza...",
defaultProxyUrl: "https://generativelanguage.googleapis.com",
defaultProxyUrl: "https://generativelanguage.googleapis.com/v1beta",
authModes: ["api-key"],
apiKind: "google-generative-ai",
defaultBaseUrls: ["https://generativelanguage.googleapis.com"],
defaultBaseUrls: ["https://generativelanguage.googleapis.com/v1beta"],
supportsCustomBaseUrl: true,
supportsModelDiscovery: true,
supportsProxyMode: true,
Expand Down
59 changes: 58 additions & 1 deletion tests/desktop/model-provider-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ describe("ModelProviderService", () => {
init?: RequestInit,
) => {
expect(String(input)).toBe(
"https://generativelanguage.googleapis.com/models",
"https://generativelanguage.googleapis.com/v1beta/models",
);
expect(init?.headers).toEqual({
"x-goog-api-key": "google-test-key",
Expand Down Expand Up @@ -446,6 +446,63 @@ describe("ModelProviderService", () => {
}
});

it("falls back to /v1/models for anthropic-compatible base URLs without a version suffix", async () => {
const env = createEnv(tempDir);
const store = new NexuConfigStore(env);
const service = createService(store, env);
const originalFetch = globalThis.fetch;
const seenUrls: string[] = [];

globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const url = String(input);
seenUrls.push(url);

expect(init?.headers).toEqual({
"x-api-key": "openrouter-test-key",
"anthropic-version": "2023-06-01",
});

if (url === "https://openrouter.ai/api/models") {
return new Response("not found", {
status: 404,
headers: { "Content-Type": "application/json" },
});
}

expect(url).toBe("https://openrouter.ai/api/v1/models");
return new Response(
JSON.stringify({
data: [{ id: "anthropic/claude-3.7-sonnet" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}) as typeof globalThis.fetch;

try {
const result = await service.verifyProvider("custom-anthropic", {
apiKey: "openrouter-test-key",
baseUrl: "https://openrouter.ai/api",
});

expect(seenUrls).toEqual([
"https://openrouter.ai/api/models",
"https://openrouter.ai/api/v1/models",
]);
expect(result).toEqual({
valid: true,
models: ["anthropic/claude-3.7-sonnet"],
});
} finally {
globalThis.fetch = originalFetch;
}
});

it("uses bundled Xiaomi MiMo models when discovery endpoint is unavailable", async () => {
const env = createEnv(tempDir);
const store = new NexuConfigStore(env);
Expand Down
Loading
Loading