Merge main into model-registry
This commit is contained in:
@@ -13,7 +13,6 @@ import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts";
|
||||
import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts";
|
||||
|
||||
describe("ModelRegistry", () => {
|
||||
let tempDir: string;
|
||||
@@ -25,7 +24,6 @@ describe("ModelRegistry", () => {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
modelsJsonPath = join(tempDir, "models.json");
|
||||
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
clearDeprecationWarningsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -33,7 +31,6 @@ describe("ModelRegistry", () => {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
clearApiKeyCache();
|
||||
clearDeprecationWarningsForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -443,6 +440,43 @@ describe("ModelRegistry", () => {
|
||||
expect(compat?.cacheControlFormat).toBe("anthropic");
|
||||
});
|
||||
|
||||
test("compat schema accepts chat template thinking configuration", () => {
|
||||
writeRawModelsJson({
|
||||
demo: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "DEMO_KEY",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "demo-model",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
compat: {
|
||||
thinkingFormat: "chat-template",
|
||||
chatTemplateKwargs: {
|
||||
preserve_thinking: true,
|
||||
thinking: { $var: "thinking.enabled" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||
const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined;
|
||||
|
||||
expect(registry.getError()).toBeUndefined();
|
||||
expect(compat?.thinkingFormat).toBe("chat-template");
|
||||
expect(compat?.chatTemplateKwargs).toEqual({
|
||||
preserve_thinking: true,
|
||||
thinking: { $var: "thinking.enabled" },
|
||||
});
|
||||
});
|
||||
|
||||
test("compat schema accepts Anthropic eager tool input streaming flag", () => {
|
||||
writeRawModelsJson({
|
||||
demo: {
|
||||
@@ -902,26 +936,87 @@ describe("ModelRegistry", () => {
|
||||
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
|
||||
});
|
||||
|
||||
test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => {
|
||||
const originalEnv = process.env.CUSTOM_NAME;
|
||||
process.env.CUSTOM_NAME = "legacy-env-key";
|
||||
test("stored API key env propagates to request auth and resolves headers", async () => {
|
||||
authStorage.set("cloudflare-ai-gateway", {
|
||||
type: "api_key",
|
||||
key: "$CLOUDFLARE_API_KEY",
|
||||
env: {
|
||||
CLOUDFLARE_API_KEY: "stored-cf-token",
|
||||
CLOUDFLARE_ACCOUNT_ID: "stored-account",
|
||||
},
|
||||
});
|
||||
writeRawModelsJson({
|
||||
"cloudflare-ai-gateway": {
|
||||
headers: { "x-account": "$CLOUDFLARE_ACCOUNT_ID" },
|
||||
},
|
||||
});
|
||||
|
||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||
const model = registry.getAll().find((m) => m.provider === "cloudflare-ai-gateway");
|
||||
expect(model).toBeDefined();
|
||||
|
||||
const auth = await registry.getApiKeyAndHeaders(model!);
|
||||
|
||||
expect(auth).toEqual({
|
||||
ok: true,
|
||||
apiKey: "stored-cf-token",
|
||||
headers: { "x-account": "stored-account" },
|
||||
env: {
|
||||
CLOUDFLARE_API_KEY: "stored-cf-token",
|
||||
CLOUDFLARE_ACCOUNT_ID: "stored-account",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("registerProvider treats uppercase apiKey and headers as literals", async () => {
|
||||
const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"];
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
process.env[key] = `env-${key}`;
|
||||
}
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||
|
||||
registry.registerProvider("legacy-provider", {
|
||||
registry.registerProvider("literal-provider", {
|
||||
...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"),
|
||||
apiKey: "CUSTOM_NAME",
|
||||
headers: { Authorization: "BEARER" },
|
||||
models: [
|
||||
{
|
||||
id: "demo-model",
|
||||
name: "demo-model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100000,
|
||||
maxTokens: 8000,
|
||||
headers: { "x-model-token": "MODEL_TOKEN" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(await registry.getApiKeyForProvider("legacy-provider")).toBe("legacy-env-key");
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead'));
|
||||
expect(await registry.getApiKeyForProvider("literal-provider")).toBe("CUSTOM_NAME");
|
||||
const model = registry.find("literal-provider", "demo-model");
|
||||
expect(model).toBeDefined();
|
||||
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
|
||||
ok: true,
|
||||
apiKey: "CUSTOM_NAME",
|
||||
headers: {
|
||||
Authorization: "BEARER",
|
||||
"x-model-token": "MODEL_TOKEN",
|
||||
},
|
||||
});
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CUSTOM_NAME;
|
||||
} else {
|
||||
process.env.CUSTOM_NAME = originalEnv;
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1617,6 +1712,25 @@ describe("ModelRegistry", () => {
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test("getAvailable filters GitHub Copilot OAuth models to account picker availability", () => {
|
||||
authStorage.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: "github-access-token",
|
||||
access: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
|
||||
expires: Date.now() + 60_000,
|
||||
availableModelIds: ["gpt-4.1"],
|
||||
});
|
||||
|
||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||
|
||||
expect(
|
||||
registry
|
||||
.getAvailable()
|
||||
.filter((m) => m.provider === "github-copilot")
|
||||
.map((m) => m.id),
|
||||
).toEqual(["gpt-4.1"]);
|
||||
});
|
||||
|
||||
test("getApiKeyAndHeaders resolves authHeader on every request", async () => {
|
||||
const tokenFile = join(tempDir, "token");
|
||||
writeFileSync(tokenFile, "token-1");
|
||||
|
||||
Reference in New Issue
Block a user