Skip to content

Commit 1968645

Browse files
committed
fix: handle anthropic-compatible model discovery fallbacks
1 parent 2de3742 commit 1968645

2 files changed

Lines changed: 152 additions & 30 deletions

File tree

apps/controller/src/services/model-provider-service.ts

Lines changed: 95 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,25 @@ function buildProviderUrl(
213213
return `${normalizedBaseUrl}${normalizedPath}`;
214214
}
215215

216+
function buildAnthropicModelDiscoveryUrls(
217+
baseUrl: string | null | undefined,
218+
): string[] {
219+
if (!baseUrl || baseUrl.trim().length === 0) {
220+
return [];
221+
}
222+
223+
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
224+
const candidateBaseUrls = [normalizedBaseUrl];
225+
226+
if (!/\/v\d+(?:alpha|beta)?$/i.test(normalizedBaseUrl)) {
227+
candidateBaseUrls.push(`${normalizedBaseUrl}/v1`);
228+
}
229+
230+
return [...new Set(candidateBaseUrls)]
231+
.map((candidateBaseUrl) => buildProviderUrl(candidateBaseUrl, "/models"))
232+
.filter((url): url is string => typeof url === "string" && url.length > 0);
233+
}
234+
216235
function buildGoogleModelsUrl(
217236
baseUrl: string | null | undefined,
218237
): string | null {
@@ -730,11 +749,21 @@ export class ModelProviderService {
730749
const resolvedBaseUrl =
731750
input.baseUrl ?? storedProvider?.baseUrl ?? defaultBaseUrl;
732751

733-
const verifyUrl =
752+
const verifyUrls =
734753
runtimePolicy.apiKind === "google-generative-ai"
735-
? (buildGoogleModelsUrl(resolvedBaseUrl) ?? "")
736-
: (buildProviderUrl(resolvedBaseUrl, "/models") ?? "");
737-
if (verifyUrl.length === 0) {
754+
? [buildGoogleModelsUrl(resolvedBaseUrl)].filter(
755+
(url): url is string => typeof url === "string" && url.length > 0,
756+
)
757+
: runtimePolicy.apiKind === "anthropic-messages"
758+
? buildAnthropicModelDiscoveryUrls(resolvedBaseUrl)
759+
: [buildProviderUrl(resolvedBaseUrl, "/models")].filter(
760+
(url): url is string => typeof url === "string" && url.length > 0,
761+
);
762+
if (verifyUrls.length === 0) {
763+
return { valid: false, error: "Unknown provider and no baseUrl given" };
764+
}
765+
const primaryVerifyUrl = verifyUrls[0];
766+
if (!primaryVerifyUrl) {
738767
return { valid: false, error: "Unknown provider and no baseUrl given" };
739768
}
740769

@@ -746,7 +775,7 @@ export class ModelProviderService {
746775
}
747776

748777
const response = await proxyFetch(
749-
buildProviderUrl(resolvedBaseUrl, "/api/tags") ?? verifyUrl,
778+
buildProviderUrl(resolvedBaseUrl, "/api/tags") ?? primaryVerifyUrl,
750779
{
751780
headers: Object.keys(headers).length > 0 ? headers : undefined,
752781
timeoutMs: 10000,
@@ -774,7 +803,7 @@ export class ModelProviderService {
774803
}
775804

776805
if (runtimePolicy.apiKind === "google-generative-ai") {
777-
const response = await proxyFetch(verifyUrl, {
806+
const response = await proxyFetch(primaryVerifyUrl, {
778807
headers: {
779808
"x-goog-api-key": apiKey,
780809
},
@@ -807,44 +836,80 @@ export class ModelProviderService {
807836
}
808837
: { Authorization: `Bearer ${apiKey}` };
809838

810-
const response = await proxyFetch(verifyUrl, {
811-
headers,
812-
timeoutMs: 10000,
813-
});
814-
if (!response.ok) {
815-
if (providerId === "minimax" && response.status === 404) {
816-
return { valid: true, models: MINI_MAX_API_MODELS };
839+
let lastStatus: number | null = null;
840+
841+
for (const verifyUrl of verifyUrls) {
842+
const response = await proxyFetch(verifyUrl, {
843+
headers,
844+
timeoutMs: 10000,
845+
});
846+
if (!response.ok) {
847+
lastStatus = response.status;
848+
if (
849+
runtimePolicy.apiKind === "anthropic-messages" &&
850+
response.status === 404
851+
) {
852+
continue;
853+
}
854+
if (providerId === "minimax" && response.status === 404) {
855+
return { valid: true, models: MINI_MAX_API_MODELS };
856+
}
857+
if (providerId === "xiaomi" && response.status === 404) {
858+
return {
859+
valid: true,
860+
models: getBundledProviderModelIds(providerId),
861+
};
862+
}
863+
return { valid: false, error: `HTTP ${response.status}` };
864+
}
865+
866+
let payload: { data?: Array<{ id: string }> };
867+
try {
868+
payload = (await response.json()) as {
869+
data?: Array<{ id: string }>;
870+
};
871+
} catch {
872+
if (runtimePolicy.apiKind === "anthropic-messages") {
873+
continue;
874+
}
875+
throw new Error("Invalid JSON response");
817876
}
818-
if (providerId === "xiaomi" && response.status === 404) {
877+
878+
if (providerId === "xiaomi") {
819879
return {
820880
valid: true,
821-
models: getBundledProviderModelIds(providerId),
881+
models:
882+
Array.isArray(payload.data) && payload.data.length > 0
883+
? payload.data.map((item) => item.id)
884+
: getBundledProviderModelIds(providerId),
822885
};
823886
}
824-
return { valid: false, error: `HTTP ${response.status}` };
825-
}
826887

827-
const payload = (await response.json()) as {
828-
data?: Array<{ id: string }>;
829-
};
830-
if (providerId === "xiaomi") {
831888
return {
832889
valid: true,
833890
models:
834891
Array.isArray(payload.data) && payload.data.length > 0
835892
? payload.data.map((item) => item.id)
836-
: getBundledProviderModelIds(providerId),
893+
: providerId === "minimax"
894+
? MINI_MAX_API_MODELS
895+
: [],
837896
};
838897
}
839898

840-
return {
841-
valid: true,
842-
models: Array.isArray(payload.data)
843-
? payload.data.map((item) => item.id)
844-
: providerId === "minimax"
845-
? MINI_MAX_API_MODELS
846-
: [],
847-
};
899+
if (providerId === "minimax" && lastStatus === 404) {
900+
return { valid: true, models: MINI_MAX_API_MODELS };
901+
}
902+
if (providerId === "xiaomi" && lastStatus === 404) {
903+
return {
904+
valid: true,
905+
models: getBundledProviderModelIds(providerId),
906+
};
907+
}
908+
if (lastStatus !== null) {
909+
return { valid: false, error: `HTTP ${lastStatus}` };
910+
}
911+
912+
return { valid: false, error: "Invalid JSON response" };
848913
} catch (error) {
849914
return {
850915
valid: false,

tests/desktop/model-provider-service.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,63 @@ describe("ModelProviderService", () => {
446446
}
447447
});
448448

449+
it("falls back to /v1/models for anthropic-compatible base URLs without a version suffix", async () => {
450+
const env = createEnv(tempDir);
451+
const store = new NexuConfigStore(env);
452+
const service = createService(store, env);
453+
const originalFetch = globalThis.fetch;
454+
const seenUrls: string[] = [];
455+
456+
globalThis.fetch = (async (
457+
input: RequestInfo | URL,
458+
init?: RequestInit,
459+
) => {
460+
const url = String(input);
461+
seenUrls.push(url);
462+
463+
expect(init?.headers).toEqual({
464+
"x-api-key": "openrouter-test-key",
465+
"anthropic-version": "2023-06-01",
466+
});
467+
468+
if (url === "https://openrouter.ai/api/models") {
469+
return new Response("not found", {
470+
status: 404,
471+
headers: { "Content-Type": "application/json" },
472+
});
473+
}
474+
475+
expect(url).toBe("https://openrouter.ai/api/v1/models");
476+
return new Response(
477+
JSON.stringify({
478+
data: [{ id: "anthropic/claude-3.7-sonnet" }],
479+
}),
480+
{
481+
status: 200,
482+
headers: { "Content-Type": "application/json" },
483+
},
484+
);
485+
}) as typeof globalThis.fetch;
486+
487+
try {
488+
const result = await service.verifyProvider("custom-anthropic", {
489+
apiKey: "openrouter-test-key",
490+
baseUrl: "https://openrouter.ai/api",
491+
});
492+
493+
expect(seenUrls).toEqual([
494+
"https://openrouter.ai/api/models",
495+
"https://openrouter.ai/api/v1/models",
496+
]);
497+
expect(result).toEqual({
498+
valid: true,
499+
models: ["anthropic/claude-3.7-sonnet"],
500+
});
501+
} finally {
502+
globalThis.fetch = originalFetch;
503+
}
504+
});
505+
449506
it("uses bundled Xiaomi MiMo models when discovery endpoint is unavailable", async () => {
450507
const env = createEnv(tempDir);
451508
const store = new NexuConfigStore(env);

0 commit comments

Comments
 (0)