1068612495
Add an explicit openai-models-list discovery type for custom providers while keeping lm-studio as a compatible alias. Custom providers can now point baseUrl at an OpenAI-compatible /v1 endpoint, provide an api key, and auto-populate models via GET {baseUrl}/models.
Discovered models merge cleanly with user-defined models from models.yml/models.json, so YAML entries still win for display name and token limits. The provider picker now explains when discovery succeeds but returns zero models, and when the configured /models endpoint responds with 404.
Fixes #970
175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { ModelRegistry, ProviderDiscoveryState } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { ModelRegistry as ModelRegistryImpl } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector";
|
|
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
|
import type { TUI } from "@oh-my-pi/pi-tui";
|
|
import { hookFetch, Snowflake } from "@oh-my-pi/pi-utils";
|
|
|
|
function normalizeRenderedText(text: string): string {
|
|
return text
|
|
.replace(/\x1b\[[0-9;]*m/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
let testTheme = await getThemeByName("dark");
|
|
|
|
function installTestTheme(): void {
|
|
if (!testTheme) {
|
|
throw new Error("Failed to load dark theme for issue-970 selector test");
|
|
}
|
|
setThemeInstance(testTheme);
|
|
}
|
|
|
|
async function createSelector(state: ProviderDiscoveryState): Promise<ModelSelectorComponent> {
|
|
const modelRegistry = {
|
|
refresh: async () => {},
|
|
refreshProvider: async () => {},
|
|
getError: () => undefined,
|
|
getAvailable: () => [],
|
|
getAll: () => [],
|
|
getDiscoverableProviders: () => [state.provider],
|
|
getCanonicalModels: () => [],
|
|
resolveCanonicalModel: () => undefined,
|
|
getProviderDiscoveryState: () => state,
|
|
} as unknown as ModelRegistry;
|
|
const ui = { requestRender: vi.fn() } as unknown as TUI;
|
|
const selector = new ModelSelectorComponent(
|
|
ui,
|
|
undefined,
|
|
Settings.isolated({}),
|
|
modelRegistry,
|
|
[],
|
|
() => {},
|
|
() => {},
|
|
);
|
|
await Bun.sleep(0);
|
|
installTestTheme();
|
|
selector.handleInput("\x1b[C");
|
|
selector.handleInput("\x1b[C");
|
|
await Bun.sleep(0);
|
|
return selector;
|
|
}
|
|
|
|
describe("issue #970 custom provider discovery", () => {
|
|
let tempDir: string;
|
|
let modelsPath: string;
|
|
let authStorage: AuthStorage;
|
|
|
|
beforeAll(async () => {
|
|
testTheme = await getThemeByName("dark");
|
|
if (!testTheme) {
|
|
throw new Error("Failed to load dark theme for issue-970 selector test");
|
|
}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
tempDir = path.join(os.tmpdir(), `pi-test-issue-970-${Snowflake.next()}`);
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
modelsPath = path.join(tempDir, "models.yml");
|
|
authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
authStorage.close();
|
|
if (tempDir && fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test("discovers custom openai-compatible models and lets YAML models override discovered fields", async () => {
|
|
fs.writeFileSync(
|
|
modelsPath,
|
|
[
|
|
"providers:",
|
|
" vllm:",
|
|
" baseUrl: http://192.168.5.3:8085/v1",
|
|
" apiKey: sk-1234",
|
|
" api: openai-completions",
|
|
" auth: apiKey",
|
|
" discovery:",
|
|
" type: openai-models-list",
|
|
" models:",
|
|
" - id: qwen3.6",
|
|
" name: Qwen3.6",
|
|
" contextWindow: 128000",
|
|
" maxTokens: 8192",
|
|
].join("\n"),
|
|
);
|
|
|
|
using _hook = hookFetch((input, init) => {
|
|
const url = String(input);
|
|
if (url !== "http://192.168.5.3:8085/v1/models") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
const headers = init?.headers as Headers | Record<string, string> | undefined;
|
|
const authHeader = headers instanceof Headers ? headers.get("Authorization") : headers?.Authorization;
|
|
expect(authHeader).toBe("Bearer sk-1234");
|
|
return new Response(JSON.stringify({ data: [{ id: "qwen3.6" }, { id: "deepseek-r1" }] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
});
|
|
|
|
const registry = new ModelRegistryImpl(authStorage, modelsPath);
|
|
await registry.refreshProvider("vllm");
|
|
|
|
const providerModels = registry.getAll().filter(model => model.provider === "vllm");
|
|
expect(providerModels.map(model => model.id).sort()).toEqual(["deepseek-r1", "qwen3.6"]);
|
|
expect(registry.getProviderDiscoveryState("vllm")?.status).toBe("ok");
|
|
|
|
const qwen = registry.find("vllm", "qwen3.6");
|
|
expect(qwen?.api).toBe("openai-completions");
|
|
expect(qwen?.provider).toBe("vllm");
|
|
expect(qwen?.name).toBe("Qwen3.6");
|
|
expect(qwen?.contextWindow).toBe(128000);
|
|
expect(qwen?.maxTokens).toBe(8192);
|
|
|
|
const deepseek = registry.find("vllm", "deepseek-r1");
|
|
expect(deepseek?.api).toBe("openai-completions");
|
|
expect(deepseek?.provider).toBe("vllm");
|
|
expect(deepseek?.name).toBe("deepseek-r1");
|
|
expect(deepseek?.contextWindow).toBe(128000);
|
|
expect(deepseek?.maxTokens).toBe(8192);
|
|
});
|
|
|
|
test("shows a provider-tab hint when discovery succeeds but returns zero models", async () => {
|
|
installTestTheme();
|
|
const selector = await createSelector({
|
|
provider: "vllm",
|
|
status: "empty",
|
|
optional: false,
|
|
stale: false,
|
|
fetchedAt: Date.now(),
|
|
models: [],
|
|
});
|
|
|
|
const rendered = normalizeRenderedText(selector.render(200).join("\n"));
|
|
expect(rendered).toContain("Discovery succeeded but returned 0 models");
|
|
expect(rendered).toContain("/models returns { data: [{ id }] }");
|
|
});
|
|
|
|
test("shows a provider-tab hint when the discovery endpoint returns 404", async () => {
|
|
installTestTheme();
|
|
const selector = await createSelector({
|
|
provider: "vllm",
|
|
status: "unavailable",
|
|
optional: false,
|
|
stale: false,
|
|
fetchedAt: Date.now(),
|
|
models: [],
|
|
error: "HTTP 404 from http://192.168.5.3:8085/v1/models",
|
|
});
|
|
|
|
const rendered = normalizeRenderedText(selector.render(200).join("\n"));
|
|
expect(rendered).toContain("http://192.168.5.3:8085/v1/models returned 404");
|
|
expect(rendered).toContain("baseUrl");
|
|
});
|
|
});
|