312 lines
16 KiB
TypeScript
312 lines
16 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { APICallError, type LanguageModelV2CallOptions } from "@ai-sdk/provider";
|
|
import { generateText } from "ai";
|
|
import { createVip98kChatModel } from "@/lib/ai/providers/vip98k-chat";
|
|
import { selectVip98kChatInvocation, vip98kChatSupportsVision } from "@/lib/ai/vip98k-chat-contract";
|
|
import { buildVip98kModelPresets, fetchVip98kCatalog } from "@/lib/ai/vip98k-models";
|
|
|
|
const compatible = {
|
|
kind: "chat", method: "POST", path: "/v1/chat/completions",
|
|
headers: { "X-Relay-Route-Option": "route_chat", Authorization: "untrusted" },
|
|
};
|
|
const native = { kind: "chat", method: "POST", path: "/v1/messages", headers: { "X-Relay-Route-Option": "route_native" } };
|
|
const catalogModel = {
|
|
id: "test-vision", type: "model", modality: "multimodal",
|
|
default_route_option_id: "route_native", invocation: native,
|
|
invocations: [native, { ...compatible, headers: {} }],
|
|
service_offers: [{
|
|
public_operation_id: "openai.chat_completions.create",
|
|
route_options: [{ id: "route_native", invocation: { ...compatible, headers: {} } }],
|
|
}],
|
|
};
|
|
const options: LanguageModelV2CallOptions = {
|
|
prompt: [{ role: "user", content: [
|
|
{ type: "text", text: "查看图片并报告颜色" },
|
|
{ type: "file", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) },
|
|
] }],
|
|
tools: [{ type: "function", name: "report_colors", inputSchema: {
|
|
type: "object", properties: { color: { type: "string" } }, required: ["color"],
|
|
} }],
|
|
toolChoice: { type: "required" },
|
|
};
|
|
function response(message: Record<string, unknown>) {
|
|
return Response.json({
|
|
id: "chat_test", model: "test-vision", created: 1,
|
|
choices: [{ index: 0, finish_reason: message.tool_calls ? "tool_calls" : "stop", message: { role: "assistant", ...message } }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
|
});
|
|
}
|
|
function toolResponse() {
|
|
return response({ content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "report_colors", arguments: '{"color":"red"}' } }] });
|
|
}
|
|
function model(baseFetch: typeof fetch, metadata: unknown = { invocation: compatible }) {
|
|
return createVip98kChatModel({ apiKey: "test-only-key", baseURL: "https://relay.example/v1/", modelId: "test-vision", metadata, fetch: baseFetch });
|
|
}
|
|
function streamResponse(chunks: unknown[]) {
|
|
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", {
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
});
|
|
}
|
|
async function readStream<T>(stream: ReadableStream<T>) {
|
|
const reader = stream.getReader();
|
|
const parts: T[] = [];
|
|
try {
|
|
while (true) {
|
|
const next = await reader.read();
|
|
if (next.done) return parts;
|
|
parts.push(next.value);
|
|
}
|
|
} finally { reader.releaseLock(); }
|
|
}
|
|
const streamChunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
|
|
id: "chat_test", model: "test-vision", created: 1,
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
});
|
|
|
|
test("uses the documented compatible protocol with the matching default route", () => {
|
|
expect(selectVip98kChatInvocation(catalogModel)).toEqual({
|
|
protocol: "openai-chat", path: "/v1/chat/completions", headers: { "X-Relay-Route-Option": "route_native" },
|
|
});
|
|
expect(selectVip98kChatInvocation({ invocation: compatible })?.headers).toEqual({ "X-Relay-Route-Option": "route_chat" });
|
|
expect(selectVip98kChatInvocation({ invocation: { ...compatible, path: "//attacker.example/v1/chat/completions" } })).toBeNull();
|
|
expect(selectVip98kChatInvocation({ id: "model_a", invocation: { ...compatible, request_example: { model: "model_b" } } })).toBeNull();
|
|
});
|
|
|
|
test("retains protocol and route during catalog-to-runtime conversion", () => {
|
|
const preset = buildVip98kModelPresets([catalogModel])[0];
|
|
const metadata = (preset.capabilities as any).vip98k;
|
|
expect(selectVip98kChatInvocation(metadata)).toEqual(selectVip98kChatInvocation(catalogModel));
|
|
expect(preset.supportsVision).toBe(true);
|
|
expect(preset.defaultParams.apiFormat).toBe("openai-chat");
|
|
});
|
|
|
|
test("classifies vision from the purchased capability, not a family name", () => {
|
|
expect(vip98kChatSupportsVision({ id: "gpt-vision-looking-name", modality: "text" })).toBe(false);
|
|
expect(vip98kChatSupportsVision({ id: "gemini-test", input_modes: ["image_url"] })).toBe(true);
|
|
expect(vip98kChatSupportsVision({ capabilities: ["image-understanding"] })).toBe(true);
|
|
const presets = buildVip98kModelPresets([
|
|
{ id: "minimax-h3", type: "video", input_modes: ["image_url"], output_modes: ["video"] },
|
|
{ id: "world-generation", type: "model", capabilities: ["world_generation"], output_modes: ["interactive_3d"] },
|
|
]);
|
|
expect(presets.map((p) => p.type)).toEqual(["video", "world"]);
|
|
expect(() => model(fetch, { invocation: { kind: "task", path: "/extensions/v1/tasks" } })).toThrow("未公布兼容的对话接口");
|
|
});
|
|
|
|
test("preserves images, tools, cancellation and route-scoped authentication in the actual SDK request", async () => {
|
|
const abort = new AbortController();
|
|
let calls = 0;
|
|
const mock: typeof fetch = async (input, init) => {
|
|
calls++;
|
|
expect(String(input)).toBe("https://relay.example/v1/chat/completions");
|
|
const headers = new Headers(init?.headers);
|
|
expect(headers.get("Authorization")).toBe("Bearer test-only-key");
|
|
expect(headers.get("X-Relay-Route-Option")).toBe("route_native");
|
|
expect(init?.signal).toBe(abort.signal);
|
|
expect(init?.redirect).toBe("error");
|
|
const body = JSON.parse(String(init?.body));
|
|
expect(body.messages[0].content[1]).toEqual({ type: "image_url", image_url: { url: "data:image/png;base64,AQID" } });
|
|
expect(body.tools[0].function.name).toBe("report_colors");
|
|
expect(body.tool_choice).toBe("required");
|
|
return toolResponse();
|
|
};
|
|
const result = await model(mock, catalogModel).doGenerate({ ...options, abortSignal: abort.signal });
|
|
expect(calls).toBe(1);
|
|
expect(result.content).toContainEqual(expect.objectContaining({ type: "tool-call", toolName: "report_colors", input: '{"color":"red"}' }));
|
|
});
|
|
|
|
test("keeps route headers isolated across two models on the same provider", async () => {
|
|
const routes: string[] = [];
|
|
const mock: typeof fetch = async (_url, init) => {
|
|
routes.push(new Headers(init?.headers).get("X-Relay-Route-Option") || "");
|
|
return toolResponse();
|
|
};
|
|
await Promise.all([
|
|
model(mock).doGenerate(options),
|
|
model(mock, { invocation: { ...compatible, headers: { "X-Relay-Route-Option": "other_route" } } }).doGenerate(options),
|
|
]);
|
|
expect(routes.sort()).toEqual(["other_route", "route_chat"]);
|
|
});
|
|
|
|
test("uses Responses only when that protocol is actually published", async () => {
|
|
const mock: typeof fetch = async (url, init) => {
|
|
expect(String(url)).toBe("https://relay.example/v1/responses");
|
|
expect(JSON.parse(String(init?.body))).toHaveProperty("input");
|
|
return Response.json({ id: "resp_test", created_at: 1, model: "test-vision", incomplete_details: null, output: [
|
|
{ type: "message", id: "msg_test", role: "assistant", content: [{ type: "output_text", text: "已完成", annotations: [] }] },
|
|
], usage: { input_tokens: 1, output_tokens: 1 } });
|
|
};
|
|
const result = await model(mock, { invocation: { ...compatible, path: "/v1/responses" } }).doGenerate({ prompt: options.prompt });
|
|
expect(result.content).toContainEqual(expect.objectContaining({ type: "text", text: "已完成" }));
|
|
});
|
|
|
|
test("does not automatically repeat a paid request whose outcome is unknown", async () => {
|
|
let calls = 0;
|
|
const mock: typeof fetch = async () => {
|
|
calls++;
|
|
return new Response(JSON.stringify({ error: { code: "request_outcome_unknown", message: "The request was sent, but its final outcome could not be confirmed." } }), { status: 503 });
|
|
};
|
|
await expect(generateText({ model: model(mock), prompt: "hello", maxRetries: 2 })).rejects.toThrow("避免重复调用和扣费");
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
test("rejects silent empty success without retrying and redacts upstream errors", async () => {
|
|
let calls = 0;
|
|
const empty: typeof fetch = async () => { calls++; return response({ content: "" }); };
|
|
await expect(generateText({ model: model(empty), prompt: "hello", maxRetries: 2 })).rejects.toThrow("未返回可用");
|
|
expect(calls).toBe(1);
|
|
const failing: typeof fetch = async () => new Response('<html>secret-key prompt private</html>', { status: 524 });
|
|
try { await model(failing).doGenerate(options); throw new Error("expected failure"); }
|
|
catch (error) {
|
|
expect(APICallError.isInstance(error)).toBe(true);
|
|
expect(String(error)).not.toMatch(/secret-key|prompt private|<html>/);
|
|
expect((error as APICallError).isRetryable).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("streams complete tool calls without waiting for a text answer", async () => {
|
|
const mock: typeof fetch = async () => streamResponse([
|
|
streamChunk({ role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "report_colors", arguments: '{"color":' } }] }),
|
|
streamChunk({ tool_calls: [{ index: 0, function: { arguments: '"red"}' } }] }),
|
|
streamChunk({}, "tool_calls"),
|
|
]);
|
|
const { stream } = await model(mock).doStream(options);
|
|
const parts = await readStream(stream);
|
|
expect(parts).toContainEqual(expect.objectContaining({ type: "tool-call", toolName: "report_colors", input: '{"color":"red"}' }));
|
|
expect(parts.at(-1)?.type).toBe("finish");
|
|
});
|
|
|
|
test("reports reasoning-only and disconnected streams as failures instead of success", async () => {
|
|
const mock: typeof fetch = async () => streamResponse([streamChunk({}, "stop")]);
|
|
const empty = await model(mock).doStream({ ...options, toolChoice: { type: "auto" } });
|
|
await expect(readStream(empty.stream)).rejects.toThrow("未返回可用");
|
|
const cut: typeof fetch = async () => streamResponse([streamChunk({ content: "部分回答" })]);
|
|
const interrupted = await model(cut).doStream(options);
|
|
await expect(readStream(interrupted.stream)).rejects.toThrow("提前中断");
|
|
});
|
|
|
|
test("does not claim success when a route silently ignores required tools", async () => {
|
|
const mock: typeof fetch = async () => response({ content: "当前环境中未提供工具" });
|
|
await expect(model(mock).doGenerate(options)).rejects.toThrow("未返回所要求的工具调用");
|
|
const streamMock: typeof fetch = async () => streamResponse([
|
|
streamChunk({ content: "当前环境中未提供工具" }), streamChunk({}, "stop"),
|
|
]);
|
|
const { stream } = await model(streamMock).doStream(options);
|
|
await expect(readStream(stream)).rejects.toThrow("未返回所要求的工具调用");
|
|
});
|
|
|
|
test("cancelling a streamed response cancels the underlying connection", async () => {
|
|
let cancelled = false;
|
|
const mock: typeof fetch = async () => new Response(new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(streamChunk({ content: "hello" }))}\n\n`));
|
|
},
|
|
cancel() { cancelled = true; },
|
|
}), { headers: { "Content-Type": "text/event-stream" } });
|
|
const { stream } = await model(mock).doStream(options);
|
|
const reader = stream.getReader();
|
|
await reader.read();
|
|
await reader.cancel();
|
|
await expect.poll(() => cancelled).toBe(true);
|
|
});
|
|
|
|
test("public executable catalog wins over generic authorized type placeholders", async () => {
|
|
const original = globalThis.fetch;
|
|
const seen: string[] = [];
|
|
globalThis.fetch = async (url, init) => {
|
|
seen.push(String(url));
|
|
if (String(url).endsWith("/api/v2/catalog/models")) {
|
|
expect(new Headers(init?.headers).has("Authorization")).toBe(false);
|
|
return Response.json({ data: { discovery: { authorized_models_path: "//attacker.example/models" }, sections: [{ models: [
|
|
{ id: "minimax-h3", type: "video", input_modes: ["image_url"], output_modes: ["video"] }, catalogModel,
|
|
] }] } });
|
|
}
|
|
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-only-key");
|
|
return Response.json({ data: [{ id: "minimax-h3", type: "model" }, { id: "test-vision" }] });
|
|
};
|
|
try {
|
|
const catalog = await fetchVip98kCatalog({ baseURL: "https://relay.example/v1/", apiKey: "test-only-key" });
|
|
expect(buildVip98kModelPresets(catalog).map((p) => p.type)).toEqual(["video", "model"]);
|
|
expect(seen).toEqual(["https://relay.example/api/v2/catalog/models", "https://relay.example/v1/models"]);
|
|
expect((buildVip98kModelPresets(catalog)[1].capabilities as any).vip98k.chatInvocation.headers).toEqual({ "X-Relay-Route-Option": "route_native" });
|
|
} finally { globalThis.fetch = original; }
|
|
});
|
|
|
|
test("legacy models discover the public contract once for concurrent calls without disclosing the key", async () => {
|
|
let catalogs = 0;
|
|
let posts = 0;
|
|
const mock: typeof fetch = async (url, init) => {
|
|
const headers = new Headers(init?.headers);
|
|
if (String(url).endsWith("/catalog/models")) {
|
|
catalogs++;
|
|
expect(headers.has("Authorization")).toBe(false);
|
|
return Response.json({ data: { sections: [{ models: [catalogModel] }] } });
|
|
}
|
|
posts++;
|
|
expect(headers.get("Authorization")).toBe("Bearer test-only-key");
|
|
expect(headers.get("X-Relay-Route-Option")).toBe("route_native");
|
|
return toolResponse();
|
|
};
|
|
const legacy = () => createVip98kChatModel({
|
|
apiKey: "test-only-key", baseURL: "https://legacy-test.example", modelId: "test-vision",
|
|
fetch: mock, discoverLegacyContract: true,
|
|
});
|
|
await Promise.all([legacy().doGenerate(options), legacy().doGenerate(options)]);
|
|
await legacy().doGenerate(options);
|
|
expect(catalogs).toBe(1);
|
|
expect(posts).toBe(3);
|
|
});
|
|
|
|
test("legacy discovery prevents an incorrectly classified video model from being charged as chat", async () => {
|
|
let posts = 0;
|
|
const mock: typeof fetch = async (url) => {
|
|
if (!String(url).endsWith("/catalog/models")) posts++;
|
|
return Response.json({ data: { sections: [{ models: [{
|
|
id: "minimax-h3", type: "video", invocation: { kind: "task", path: "/extensions/v1/tasks" },
|
|
}] }] } });
|
|
};
|
|
const legacy = createVip98kChatModel({
|
|
apiKey: "test-only-key", baseURL: "https://legacy-video.example", modelId: "minimax-h3",
|
|
fetch: mock, discoverLegacyContract: true,
|
|
});
|
|
await expect(legacy.doGenerate(options)).rejects.toThrow("未公布兼容的对话接口");
|
|
expect(posts).toBe(0);
|
|
});
|
|
|
|
test("a legacy native-only snapshot can discover its newly advertised compatible route", async () => {
|
|
let posts = 0;
|
|
const mock: typeof fetch = async (url, init) => {
|
|
if (String(url).endsWith("/catalog/models")) {
|
|
return Response.json({ data: { sections: [{ models: [catalogModel] }] } });
|
|
}
|
|
expect(String(url)).toBe("https://legacy-native.example/v1/chat/completions");
|
|
expect(new Headers(init?.headers).get("X-Relay-Route-Option")).toBe("route_native");
|
|
posts++;
|
|
return toolResponse();
|
|
};
|
|
const legacy = createVip98kChatModel({
|
|
apiKey: "test-only-key", baseURL: "https://legacy-native.example", modelId: "test-vision",
|
|
metadata: { invocation: native }, fetch: mock, discoverLegacyContract: true,
|
|
});
|
|
await legacy.doGenerate(options);
|
|
expect(posts).toBe(1);
|
|
});
|
|
|
|
test("a cancelled request can leave shared discovery without submitting a paid POST", async () => {
|
|
const abort = new AbortController();
|
|
let finishCatalog: (response: Response) => void = () => {};
|
|
let posts = 0;
|
|
const mock: typeof fetch = async (url) => {
|
|
if (!String(url).endsWith("/catalog/models")) { posts++; return toolResponse(); }
|
|
return new Promise<Response>((resolve) => { finishCatalog = resolve; });
|
|
};
|
|
const legacy = createVip98kChatModel({
|
|
apiKey: "test-only-key", baseURL: "https://legacy-cancel.example", modelId: "test-vision",
|
|
fetch: mock, discoverLegacyContract: true,
|
|
});
|
|
const result = Promise.resolve(legacy.doGenerate({ ...options, abortSignal: abort.signal }));
|
|
abort.abort(new Error("cancelled by user"));
|
|
await expect(result).rejects.toThrow("cancelled by user");
|
|
finishCatalog(Response.json({ data: { sections: [{ models: [catalogModel] }] } }));
|
|
expect(posts).toBe(0);
|
|
});
|