fix: warm discovery backed providers

This commit is contained in:
Mikael Hugo 2026-05-05 17:05:44 +02:00
parent c6fe3b2b79
commit 6fee7e60c8
5 changed files with 127 additions and 23 deletions

View file

@ -255,7 +255,7 @@ describe("singularity-memory discovery", () => {
// ─── Ollama Cloud Adapter ───────────────────────────────────────────────────
describe("ollama-cloud discovery", () => {
it("uses the live Ollama /api/tags endpoint", async () => {
it("uses the live Ollama /v1/models endpoint", async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers?: RequestInit["headers"] }> = [];
globalThis.fetch = (async (
@ -265,10 +265,7 @@ describe("ollama-cloud discovery", () => {
calls.push({ url: String(input), headers: init?.headers });
return new Response(
JSON.stringify({
models: [
{ name: "kimi-k2.5", size: 1 },
{ model: "kimi-k2.6", size: 1 },
],
data: [{ id: "kimi-k2.5" }, { id: "kimi-k2.6" }],
}),
{ status: 200 },
);
@ -278,7 +275,7 @@ describe("ollama-cloud discovery", () => {
const adapter = getDiscoveryAdapter("ollama-cloud");
const models = await adapter.fetchModels("test-key");
assert.equal(calls[0]?.url, "https://ollama.com/api/tags");
assert.equal(calls[0]?.url, "https://ollama.com/v1/models");
assert.deepEqual(calls[0]?.headers, {
Authorization: "Bearer test-key",
});
@ -286,6 +283,41 @@ describe("ollama-cloud discovery", () => {
models.map((m) => m.id),
["kimi-k2.5", "kimi-k2.6"],
);
assert.ok(models.every((m) => m.api === "openai-completions"));
assert.ok(models.every((m) => m.baseUrl === "https://ollama.com/v1"));
} finally {
globalThis.fetch = originalFetch;
}
});
it("falls back to /api/tags when /v1/models is unavailable", async () => {
const originalFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
calls.push(String(input));
if (calls.length === 1) {
return new Response("not found", { status: 404 });
}
return new Response(
JSON.stringify({
models: [{ name: "glm-5.1" }, { model: "qwen3-coder:480b" }],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const adapter = getDiscoveryAdapter("ollama-cloud");
const models = await adapter.fetchModels("test-key");
assert.deepEqual(calls, [
"https://ollama.com/v1/models",
"https://ollama.com/api/tags",
]);
assert.deepEqual(
models.map((m) => m.id),
["glm-5.1", "qwen3-coder:480b"],
);
} finally {
globalThis.fetch = originalFetch;
}

View file

@ -117,28 +117,54 @@ class OllamaCloudDiscoveryAdapter implements ProviderDiscoveryAdapter {
baseUrl?: string,
): Promise<DiscoveredModel[]> {
const root = (baseUrl ?? "https://ollama.com").replace(/\/+$/, "");
const url = root.endsWith("/api/tags") ? root : `${root}/api/tags`;
const primaryUrl =
root.endsWith("/v1/models") || root.endsWith("/api/tags")
? root
: `${root}/v1/models`;
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
const response = await fetchWithTimeout(url, { headers });
const response = await fetchWithTimeout(primaryUrl, { headers });
if (!response.ok) {
throw new Error(
`Ollama Cloud tags API returned ${response.status}: ${response.statusText}`,
);
if (response.ok) {
return this.mapOpenAIModelsResponse(await response.json());
}
const data = (await response.json()) as {
models: Array<{ name?: string; model?: string; size?: number }>;
};
if (primaryUrl.endsWith("/v1/models")) {
const fallbackUrl = `${root}/api/tags`;
const fallback = await fetchWithTimeout(fallbackUrl, { headers });
if (fallback.ok) return this.mapTagsResponse(await fallback.json());
}
return (data.models ?? [])
throw new Error(
`Ollama Cloud models API returned ${response.status}: ${response.statusText}`,
);
}
private mapOpenAIModelsResponse(data: unknown): DiscoveredModel[] {
const models = (data as { data?: Array<{ id?: string }> }).data ?? [];
return models
.map((m) => m.id)
.filter((id): id is string => typeof id === "string" && id.length > 0)
.map((id) => this.toOpenAICompletionsModel(id));
}
private mapTagsResponse(data: unknown): DiscoveredModel[] {
const models =
(data as { models?: Array<{ name?: string; model?: string }> }).models ??
[];
return models
.map((m) => m.name ?? m.model)
.filter((id): id is string => typeof id === "string" && id.length > 0)
.map((id) => ({
id,
name: id,
input: ["text" as const],
}));
.map((id) => this.toOpenAICompletionsModel(id));
}
private toOpenAICompletionsModel(id: string): DiscoveredModel {
return {
id,
name: id,
api: "openai-completions",
baseUrl: "https://ollama.com/v1",
input: ["text" as const],
};
}
}

View file

@ -128,7 +128,7 @@ describe("ModelRegistry — public discovery providers", () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
models: [{ name: "kimi-k2.5" }, { model: "kimi-k2.6" }],
data: [{ id: "kimi-k2.5" }, { id: "kimi-k2.6" }],
}),
{ status: 200 },
)) as typeof fetch;
@ -157,6 +157,37 @@ describe("ModelRegistry — public discovery providers", () => {
}
});
it("makes discovered ollama-cloud models available when auth is configured", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: [{ id: "kimi-k2.6" }],
}),
{ status: 200 },
)) as typeof fetch;
try {
const registry = new ModelRegistry(
AuthStorage.inMemory({
"ollama-cloud": { type: "api_key", key: "ollama-test" },
}),
undefined,
new ModelDiscoveryCache(join(testDir, "ollama-cloud-auth-cache.json")),
);
await registry.discoverModels(["ollama-cloud"]);
assert.ok(
registry
.getAvailable()
.some((m) => m.provider === "ollama-cloud" && m.id === "kimi-k2.6"),
"discovered Ollama Cloud models are available after discovery",
);
} finally {
globalThis.fetch = originalFetch;
}
});
it("discovers direct provider models when auth is configured", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>

View file

@ -839,7 +839,9 @@ export class ModelRegistry {
*/
getAvailable(providerModelAllow?: ProviderModelAllowList): Model<Api>[] {
return this.filterProviderModelAllow(
this.models.filter((m) => this.isProviderRequestReady(m.provider)),
this.getAllWithDiscovered().filter((m) =>
this.isProviderRequestReady(m.provider),
),
providerModelAllow,
);
}

View file

@ -69,6 +69,17 @@ function exitIfManagedResourcesAreNewer(currentAgentDir: string): void {
process.exit(1);
}
async function warmDiscoveryBackedProviders(
modelRegistry: ModelRegistry,
): Promise<void> {
const providers = ["ollama-cloud"].filter((provider) =>
modelRegistry.isProviderRequestReady(provider),
);
if (providers.length === 0) return;
await modelRegistry.discoverModels(providers);
}
// ---------------------------------------------------------------------------
// Shared helpers used by both the print and interactive code paths
// ---------------------------------------------------------------------------
@ -603,6 +614,8 @@ const modelsJsonPath = resolveModelsJsonPath();
const modelRegistry = new ModelRegistry(authStorage, modelsJsonPath);
markStartup("ModelRegistry");
await warmDiscoveryBackedProviders(modelRegistry);
markStartup("ModelRegistry.discovery");
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
applySecurityOverrides(settingsManager);
markStartup("SettingsManager.create");