8a0903ebf2
The root barrel is now core-only and side-effect free: types, createModels/createProvider, auth substrate, lazyStream/lazyApi, faux, utils. Generated catalogs, api-registry, env-api-keys, images, global stream functions, and per-API lazy wrappers leave the root. New @earendil-works/pi-ai/compat preserves the old surface verbatim as a strict superset of the root: api-dispatch stream/complete with env key injection, the builtin registration side effect (skip-if-present so it cannot clobber earlier overrides), deprecated getModel/getModels/ getProviders aliases of the new getBuiltin* reads in providers/all, lazy api wrappers + setBedrockProviderModule, and image generation. Compat dies with the coding-agent ModelManager migration. Packaging: exports map gains ./compat, ./providers/*, ./api/*; sideEffects array lists only the effectful modules. Old-global imports across agent/coding-agent/examples and pi-ai tests switch to /compat (path-only; compat is a superset). The coding-agent extension loader resolves the pi-ai ROOT specifier to compat, so existing user extensions using the old global API keep working at runtime until compat is removed. vitest configs alias /compat to src; browser smoke imports old globals from /compat.
103 lines
2.8 KiB
TypeScript
103 lines
2.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { convertMessages } from "../src/api/openai-completions.ts";
|
|
import { getModel } from "../src/compat.ts";
|
|
import type {
|
|
AssistantMessage,
|
|
Context,
|
|
Model,
|
|
OpenAICompletionsCompat,
|
|
ToolResultMessage,
|
|
Usage,
|
|
} from "../src/types.ts";
|
|
|
|
const emptyUsage: Usage = {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
|
|
const compat: Required<OpenAICompletionsCompat> = {
|
|
supportsStore: true,
|
|
supportsDeveloperRole: true,
|
|
supportsReasoningEffort: true,
|
|
supportsUsageInStreaming: true,
|
|
maxTokensField: "max_completion_tokens",
|
|
requiresToolResultName: false,
|
|
requiresAssistantAfterToolResult: false,
|
|
requiresThinkingAsText: false,
|
|
requiresReasoningContentOnAssistantMessages: false,
|
|
thinkingFormat: "openai",
|
|
openRouterRouting: {},
|
|
vercelGatewayRouting: {},
|
|
zaiToolStream: false,
|
|
supportsStrictMode: true,
|
|
cacheControlFormat: "anthropic",
|
|
sendSessionAffinityHeaders: false,
|
|
supportsLongCacheRetention: true,
|
|
};
|
|
|
|
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId,
|
|
toolName: "read",
|
|
content: [
|
|
{ type: "text", text: "Read image file [image/png]" },
|
|
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
|
|
],
|
|
isError: false,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
describe("openai-completions convertMessages", () => {
|
|
it("batches tool-result images after consecutive tool results", () => {
|
|
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
|
const model: Model<"openai-completions"> = {
|
|
...baseModel,
|
|
api: "openai-completions",
|
|
input: ["text", "image"],
|
|
};
|
|
|
|
const now = Date.now();
|
|
const assistantMessage: AssistantMessage = {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "img-1.png" } },
|
|
{ type: "toolCall", id: "tool-2", name: "read", arguments: { path: "img-2.png" } },
|
|
],
|
|
api: model.api,
|
|
provider: model.provider,
|
|
model: model.id,
|
|
usage: emptyUsage,
|
|
stopReason: "toolUse",
|
|
timestamp: now,
|
|
};
|
|
|
|
const context: Context = {
|
|
messages: [
|
|
{ role: "user", content: "Read the images", timestamp: now - 2 },
|
|
assistantMessage,
|
|
buildToolResult("tool-1", now + 1),
|
|
buildToolResult("tool-2", now + 2),
|
|
],
|
|
};
|
|
|
|
const messages = convertMessages(model, context, compat);
|
|
const roles = messages.map((message) => message.role);
|
|
expect(roles).toEqual(["user", "assistant", "tool", "tool", "user"]);
|
|
|
|
const imageMessage = messages[messages.length - 1];
|
|
expect(imageMessage.role).toBe("user");
|
|
expect(Array.isArray(imageMessage.content)).toBe(true);
|
|
|
|
const imageParts = (imageMessage.content as Array<{ type?: string }>).filter(
|
|
(part) => part?.type === "image_url",
|
|
);
|
|
expect(imageParts.length).toBe(2);
|
|
});
|
|
});
|