305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
buildAuthHeaders,
|
|
buildChatUrl,
|
|
buildEmbeddingUrl,
|
|
detectAzure,
|
|
normalizeBaseUrl,
|
|
} from "../src/providers/_openai-shared.js";
|
|
import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js";
|
|
|
|
describe("_openai-shared — detectAzure", () => {
|
|
it("detects standard Azure resource hostname", () => {
|
|
expect(
|
|
detectAzure(
|
|
"https://myresource.openai.azure.com/openai/deployments/mydeploy",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not flag api.openai.com", () => {
|
|
expect(detectAzure("https://api.openai.com")).toBe(false);
|
|
});
|
|
|
|
it("does not flag DeepSeek / SiliconFlow / Ollama / vLLM", () => {
|
|
expect(detectAzure("https://api.deepseek.com/v1")).toBe(false);
|
|
expect(detectAzure("https://api.siliconflow.cn")).toBe(false);
|
|
expect(detectAzure("http://localhost:11434/v1")).toBe(false);
|
|
expect(detectAzure("http://localhost:8000/v1")).toBe(false);
|
|
});
|
|
|
|
it("returns false for malformed URLs", () => {
|
|
expect(detectAzure("not-a-url")).toBe(false);
|
|
expect(detectAzure("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("_openai-shared — buildChatUrl", () => {
|
|
it("appends /v1/chat/completions for standard OpenAI", () => {
|
|
expect(buildChatUrl("https://api.openai.com", false, "2024-08-01-preview")).toBe(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
);
|
|
});
|
|
|
|
it("appends /chat/completions + api-version for Azure", () => {
|
|
const url = buildChatUrl(
|
|
"https://myresource.openai.azure.com/openai/deployments/mydeploy",
|
|
true,
|
|
"2024-08-01-preview",
|
|
);
|
|
expect(url).toBe(
|
|
"https://myresource.openai.azure.com/openai/deployments/mydeploy/chat/completions?api-version=2024-08-01-preview",
|
|
);
|
|
});
|
|
|
|
it("URL-encodes the api-version", () => {
|
|
const url = buildChatUrl(
|
|
"https://r.openai.azure.com/openai/deployments/d",
|
|
true,
|
|
"preview/with/slashes",
|
|
);
|
|
expect(url).toContain("api-version=preview%2Fwith%2Fslashes");
|
|
});
|
|
|
|
it("preserves pre-existing query params on the base URL (CodeRabbit catch)", () => {
|
|
// A corporate proxy or diagnostics endpoint might already carry
|
|
// query parameters on the base URL. String-concat would have
|
|
// interpolated the route path into the query string; URL-API
|
|
// composition keeps the query intact and adds api-version
|
|
// alongside.
|
|
const url = buildChatUrl(
|
|
"https://proxy.example.com/openai/deployments/d?tenant=acme",
|
|
true,
|
|
"2024-08-01-preview",
|
|
);
|
|
const parsed = new URL(url);
|
|
expect(parsed.pathname).toBe("/openai/deployments/d/chat/completions");
|
|
expect(parsed.searchParams.get("tenant")).toBe("acme");
|
|
expect(parsed.searchParams.get("api-version")).toBe("2024-08-01-preview");
|
|
});
|
|
|
|
it("strips trailing slashes from base path before joining route", () => {
|
|
const url = buildChatUrl(
|
|
"https://r.openai.azure.com/openai/deployments/d/",
|
|
true,
|
|
"2024-08-01-preview",
|
|
);
|
|
expect(new URL(url).pathname).toBe("/openai/deployments/d/chat/completions");
|
|
});
|
|
|
|
it("routes through /openai/v1 when the base URL has no /deployments/ segment (Azure v1 GA)", () => {
|
|
// Azure shipped a v1 URL pattern that mirrors the OpenAI shape:
|
|
// /openai/v1/chat/completions, deployment passed in the body as
|
|
// `model`. No api-version query param.
|
|
const url = buildChatUrl(
|
|
"https://r.openai.azure.com",
|
|
true,
|
|
"2024-08-01-preview", // ignored on v1
|
|
);
|
|
const parsed = new URL(url);
|
|
expect(parsed.pathname).toBe("/openai/v1/chat/completions");
|
|
expect(parsed.searchParams.get("api-version")).toBeNull();
|
|
});
|
|
|
|
it("strips a trailing /openai or /openai/v1 prefix when composing v1 URLs", () => {
|
|
// Users may pre-configure OPENAI_BASE_URL with the /openai/v1
|
|
// suffix already present. We should not double it.
|
|
const fromOpenai = buildChatUrl(
|
|
"https://r.openai.azure.com/openai",
|
|
true,
|
|
"ignored",
|
|
);
|
|
expect(new URL(fromOpenai).pathname).toBe("/openai/v1/chat/completions");
|
|
|
|
const fromV1 = buildChatUrl(
|
|
"https://r.openai.azure.com/openai/v1",
|
|
true,
|
|
"ignored",
|
|
);
|
|
expect(new URL(fromV1).pathname).toBe("/openai/v1/chat/completions");
|
|
});
|
|
});
|
|
|
|
describe("_openai-shared — buildEmbeddingUrl", () => {
|
|
it("appends /v1/embeddings for standard OpenAI", () => {
|
|
expect(
|
|
buildEmbeddingUrl("https://api.openai.com", false, "2024-08-01-preview"),
|
|
).toBe("https://api.openai.com/v1/embeddings");
|
|
});
|
|
|
|
it("appends /embeddings + api-version for Azure legacy (no /v1/ prefix)", () => {
|
|
const url = buildEmbeddingUrl(
|
|
"https://r.openai.azure.com/openai/deployments/embed-deploy",
|
|
true,
|
|
"2024-08-01-preview",
|
|
);
|
|
expect(url).toBe(
|
|
"https://r.openai.azure.com/openai/deployments/embed-deploy/embeddings?api-version=2024-08-01-preview",
|
|
);
|
|
});
|
|
|
|
it("routes through /openai/v1/embeddings on Azure v1 (no api-version)", () => {
|
|
const url = buildEmbeddingUrl(
|
|
"https://r.openai.azure.com",
|
|
true,
|
|
"2024-08-01-preview", // ignored on v1
|
|
);
|
|
const parsed = new URL(url);
|
|
expect(parsed.pathname).toBe("/openai/v1/embeddings");
|
|
expect(parsed.searchParams.get("api-version")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("_openai-shared — non-OpenAI base URLs (#628, #646)", () => {
|
|
it("does not double /v1 when base URL already ends with /v1 (DeepSeek shape, #628)", () => {
|
|
expect(
|
|
buildChatUrl("https://api.deepseek.com/v1", false, "2024-08-01-preview"),
|
|
).toBe("https://api.deepseek.com/v1/chat/completions");
|
|
expect(
|
|
buildEmbeddingUrl("https://api.deepseek.com/v1", false, "2024-08-01-preview"),
|
|
).toBe("https://api.deepseek.com/v1/embeddings");
|
|
});
|
|
|
|
it("does not inject /v1 when provider uses non-OpenAI version segment (Zhipu /api/paas/v4, #646)", () => {
|
|
expect(
|
|
buildChatUrl(
|
|
"https://open.bigmodel.cn/api/paas/v4",
|
|
false,
|
|
"2024-08-01-preview",
|
|
),
|
|
).toBe("https://open.bigmodel.cn/api/paas/v4/chat/completions");
|
|
expect(
|
|
buildEmbeddingUrl(
|
|
"https://open.bigmodel.cn/api/paas/v4",
|
|
false,
|
|
"2024-08-01-preview",
|
|
),
|
|
).toBe("https://open.bigmodel.cn/api/paas/v4/embeddings");
|
|
});
|
|
|
|
it("tolerates trailing slash on already-versioned base", () => {
|
|
expect(
|
|
buildChatUrl("https://api.deepseek.com/v1/", false, "2024-08-01-preview"),
|
|
).toBe("https://api.deepseek.com/v1/chat/completions");
|
|
});
|
|
|
|
it("handles localhost OpenAI-compatible servers with explicit /v1", () => {
|
|
expect(
|
|
buildChatUrl("http://localhost:11434/v1", false, "2024-08-01-preview"),
|
|
).toBe("http://localhost:11434/v1/chat/completions");
|
|
expect(
|
|
buildChatUrl("http://localhost:8000/v1", false, "2024-08-01-preview"),
|
|
).toBe("http://localhost:8000/v1/chat/completions");
|
|
});
|
|
});
|
|
|
|
describe("_openai-shared — buildAuthHeaders", () => {
|
|
it("emits Authorization: Bearer for standard OpenAI", () => {
|
|
expect(buildAuthHeaders("sk-test", false)).toEqual({
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer sk-test",
|
|
});
|
|
});
|
|
|
|
it("emits api-key header for Azure", () => {
|
|
expect(buildAuthHeaders("azure-key", true)).toEqual({
|
|
"Content-Type": "application/json",
|
|
"api-key": "azure-key",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("_openai-shared — normalizeBaseUrl", () => {
|
|
it("returns default when no value passed", () => {
|
|
expect(normalizeBaseUrl(undefined)).toBe("https://api.openai.com");
|
|
expect(normalizeBaseUrl("")).toBe("https://api.openai.com");
|
|
});
|
|
|
|
it("strips trailing slashes", () => {
|
|
expect(normalizeBaseUrl("https://api.deepseek.com/v1///")).toBe(
|
|
"https://api.deepseek.com/v1",
|
|
);
|
|
});
|
|
|
|
it("returns explicit values unchanged otherwise", () => {
|
|
expect(normalizeBaseUrl("https://api.deepseek.com/v1")).toBe(
|
|
"https://api.deepseek.com/v1",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// OpenAIEmbeddingProvider — Azure transport (#371)
|
|
// Verifies the embedding path now uses the shared Azure helpers:
|
|
// hits /embeddings (not /v1/embeddings), includes api-version, uses
|
|
// api-key header instead of Authorization: Bearer.
|
|
// ─────────────────────────────────────────────────────────────
|
|
describe("OpenAIEmbeddingProvider — Azure auto-detection (#371)", () => {
|
|
const ORIGINAL_BASE = process.env["OPENAI_BASE_URL"];
|
|
const ORIGINAL_VERSION = process.env["OPENAI_API_VERSION"];
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_BASE === undefined) delete process.env["OPENAI_BASE_URL"];
|
|
else process.env["OPENAI_BASE_URL"] = ORIGINAL_BASE;
|
|
if (ORIGINAL_VERSION === undefined) delete process.env["OPENAI_API_VERSION"];
|
|
else process.env["OPENAI_API_VERSION"] = ORIGINAL_VERSION;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("uses Azure shape when OPENAI_BASE_URL points at *.openai.azure.com", async () => {
|
|
process.env["OPENAI_BASE_URL"] =
|
|
"https://myres.openai.azure.com/openai/deployments/embed-d";
|
|
process.env["OPENAI_API_VERSION"] = "2024-08-01-preview";
|
|
|
|
let capturedUrl = "";
|
|
let capturedHeaders = new Headers();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
async (url: string | URL | Request, init?: RequestInit) => {
|
|
capturedUrl = String(url);
|
|
capturedHeaders = new Headers(init?.headers);
|
|
return new Response(
|
|
JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }),
|
|
{ status: 200 },
|
|
);
|
|
},
|
|
);
|
|
|
|
const provider = new OpenAIEmbeddingProvider("azure-key");
|
|
await provider.embedBatch(["hello"]);
|
|
|
|
expect(capturedUrl).toBe(
|
|
"https://myres.openai.azure.com/openai/deployments/embed-d/embeddings?api-version=2024-08-01-preview",
|
|
);
|
|
expect(capturedHeaders.get("api-key")).toBe("azure-key");
|
|
expect(capturedHeaders.get("Authorization")).toBeNull();
|
|
});
|
|
|
|
it("uses standard shape when OPENAI_BASE_URL points at api.openai.com", async () => {
|
|
process.env["OPENAI_BASE_URL"] = "https://api.openai.com";
|
|
|
|
let capturedUrl = "";
|
|
let capturedHeaders = new Headers();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
async (url: string | URL | Request, init?: RequestInit) => {
|
|
capturedUrl = String(url);
|
|
capturedHeaders = new Headers(init?.headers);
|
|
return new Response(
|
|
JSON.stringify({ data: [{ embedding: [0.4, 0.5, 0.6] }] }),
|
|
{ status: 200 },
|
|
);
|
|
},
|
|
);
|
|
|
|
const provider = new OpenAIEmbeddingProvider("sk-test");
|
|
await provider.embedBatch(["hello"]);
|
|
|
|
expect(capturedUrl).toBe("https://api.openai.com/v1/embeddings");
|
|
expect(capturedHeaders.get("Authorization")).toBe("Bearer sk-test");
|
|
expect(capturedHeaders.get("api-key")).toBeNull();
|
|
});
|
|
});
|