Files
openclaw/packages/ai/src/providers/google-shared.convert.test.ts
T
Peter Steinberger 062f88e3e3 refactor: extract reusable AI runtime package (#99059)
* refactor: extract reusable AI runtime package

* refactor: complete AI provider relocation

* refactor: keep llm core internal

* refactor(ai): make @openclaw/ai self-contained with host policy ports

Move pure transport helpers (tool projections, strict-schema normalization,
prompt-cache boundary, stream guards, anthropic/openai compat, request
activity) from src into packages/ai; move utf16-slice into
normalization-core. Inject host policy (guarded fetch, redaction,
strict-tool defaults, diagnostics logging) through AiTransportHost with
inert library defaults installed by src/llm/stream.ts. Narrow the public
barrel to instance-scoped createApiRegistry/createLlmRuntime; the
process-default runtime moves behind internal/ and
registerBuiltInApiProviders takes an explicit registry. Delete the
src/llm/api-registry re-export facade.

* fix(ai): teach node, jiti, and vite resolvers the @openclaw/ai and utf16-slice subpaths

The workspace alias tables in root-alias.cjs, plugin-sdk-native-resolver,
sdk-alias, the shared vitest config, and the Control UI vite config only
knew @openclaw/llm-core; Node-side plugin loading resolved @openclaw/ai
through the pnpm symlink to the unbuilt dist (checks-node-compact CI
failures), and the Control UI build broke on the new
normalization-core/utf16-slice subpath.

* chore(ui): drop leftover service-worker debug logging

* build(release): ship @openclaw/ai with its own shrinkwrap and honest dependency set

packages/ai declares only its six real runtime deps (kysely, chalk, json5,
tslog, zod, fs-safe, and proxyline were never imported); orphaned root deps
removed. generate-npm-shrinkwrap now treats publishable packages/* like
publishable plugins so the AI tarball pins its transitive tree even though
workspace deps are omitted from the root shrinkwrap. knip learns the
package entry points; the tsdown dts neverBundle option moves to its
documented deps.dts home; the README documents the no-semver internal/*
contract and host ports.

* docs(ai): add minimal external-consumer example app

examples/ai-chat consumes only the public @openclaw/ai surface (built dist
via the workspace link): isolated runtime, built-in provider registration,
one streamed completion. Supports Anthropic/OpenAI via env keys and a
keyless local Ollama target; live-verified against Ollama.

* docs(ai): document the @openclaw/ai package and workspace shrinkwrap boundary

* chore(check): include examples/ in duplicate-scan targets

* fix: emit normalization package subpaths

* fix: complete AI package boundary artifacts

* fix: align AI package boundary contracts

* fix(ci): stabilize package release contracts

* test: align documentation contract checks

* test: keep cron docs guard aligned

* test: align restored docs contract guards

* test: follow upstream docs contracts

* docs: drop superseded talk wording
2026-07-05 01:56:40 -04:00

404 lines
12 KiB
TypeScript

// Google shared conversion tests cover runtime-to-Google payload conversion.
import { describe, expect, it } from "vitest";
import type { Context, Tool } from "../types.js";
import { convertMessages, convertTools } from "./google-shared.js";
import {
asRecord,
expectConvertedRoles,
getFirstToolParameters,
makeGeminiCliAssistantMessage,
makeGeminiCliModel,
makeGoogleAssistantMessage,
makeModel,
} from "./google-shared.test-helpers.js";
type GoogleSharedTestModel = ReturnType<typeof makeModel> | ReturnType<typeof makeGeminiCliModel>;
const convertMessagesForTest = convertMessages as unknown as (
model: GoogleSharedTestModel,
context: Context,
) => ReturnType<typeof convertMessages>;
function requireRecordProperty(
record: Record<string, unknown>,
key: string,
): Record<string, unknown> {
const value = record[key];
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected object property ${key}`);
}
return value as Record<string, unknown>;
}
describe("google-shared convertTools", () => {
it("preserves parameters when type is missing", () => {
const tools = [
{
name: "noType",
description: "Tool with properties but no type",
parameters: {
properties: {
action: { type: "string" },
},
required: ["action"],
},
},
] as unknown as Tool[];
const converted = convertTools(tools);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
expect(params.type).toBeUndefined();
expect(params.properties).toEqual({
action: { type: "string" },
});
expect(params.required).toEqual(["action"]);
});
it("keeps unsupported JSON Schema keywords intact", () => {
const tools = [
{
name: "example",
description: "Example tool",
parameters: {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
additionalProperties: false,
properties: {
mode: {
type: "string",
const: "fast",
},
options: {
anyOf: [{ type: "string" }, { type: "number" }],
},
list: {
type: "array",
items: {
type: "string",
const: "item",
},
},
},
required: ["mode"],
},
},
] as unknown as Tool[];
const converted = convertTools(tools);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
const properties = asRecord(params.properties);
const mode = asRecord(properties.mode);
const options = asRecord(properties.options);
const list = asRecord(properties.list);
const items = asRecord(list.items);
expect(params.patternProperties).toEqual({ "^x-": { type: "string" } });
expect(params.additionalProperties).toBe(false);
expect(mode.const).toBe("fast");
expect(options.anyOf).toEqual([{ type: "string" }, { type: "number" }]);
expect(items.const).toBe("item");
expect(params.required).toEqual(["mode"]);
});
it("keeps supported schema fields", () => {
const tools = [
{
name: "settings",
description: "Settings tool",
parameters: {
type: "object",
properties: {
config: {
type: "object",
properties: {
retries: { type: "number", minimum: 1 },
tags: {
type: "array",
items: { type: "string" },
},
},
required: ["retries"],
},
},
required: ["config"],
},
},
] as unknown as Tool[];
const converted = convertTools(tools);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
const config = asRecord(asRecord(params.properties).config);
const configProps = asRecord(config.properties);
const retries = asRecord(configProps.retries);
const tags = asRecord(configProps.tags);
const items = asRecord(tags.items);
expect(params.type).toBe("object");
expect(config.type).toBe("object");
expect(retries.minimum).toBe(1);
expect(tags.type).toBe("array");
expect(items.type).toBe("string");
expect(config.required).toEqual(["retries"]);
expect(params.required).toEqual(["config"]);
});
});
describe("google-shared convertMessages", () => {
function expectConsecutiveMessagesNotMerged(params: {
modelId: string;
first: string;
second: string;
}) {
const model = makeModel(params.modelId);
const context = {
messages: [
{
role: "user",
content: params.first,
},
{
role: "user",
content: params.second,
},
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("user");
expect(contents[0].parts).toHaveLength(1);
expect(contents[1].parts).toHaveLength(1);
}
it("keeps thinking blocks when provider/model match", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
makeGoogleAssistantMessage(model.id, [
{
type: "thinking",
thinking: "hidden",
thinkingSignature: "c2ln",
},
]),
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
expect(contents).toHaveLength(1);
expect(contents[0].role).toBe("model");
const part = asRecord(contents[0].parts?.[0]);
expect(part.thought).toBe(true);
expect(part.thoughtSignature).toBe("c2ln");
});
it("keeps thought signatures for Claude models", () => {
const model = makeModel("claude-3-opus");
const context = {
messages: [
makeGoogleAssistantMessage(model.id, [
{
type: "thinking",
thinking: "structured",
thinkingSignature: "c2ln",
},
]),
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
const parts = contents?.[0]?.parts ?? [];
expect(parts).toHaveLength(1);
const part = asRecord(parts[0]);
expect(part.thought).toBe(true);
expect(part.thoughtSignature).toBe("c2ln");
});
it("does not merge consecutive user messages for Gemini", () => {
expectConsecutiveMessagesNotMerged({
modelId: "gemini-1.5-pro",
first: "Hello",
second: "How are you?",
});
});
it("does not merge consecutive user messages for non-Gemini Google models", () => {
expectConsecutiveMessagesNotMerged({
modelId: "claude-3-opus",
first: "First",
second: "Second",
});
});
it("does not merge consecutive model messages for Gemini", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Hello",
},
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi there!" }]),
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "How can I help?" }]),
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
expectConvertedRoles(contents, ["user", "model", "model"]);
expect(contents[1].parts).toHaveLength(1);
expect(contents[2].parts).toHaveLength(1);
});
it("handles user message after tool result without model response in between", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Use a tool",
},
makeGoogleAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: { arg: "value" },
},
]),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "myTool",
content: [{ type: "text", text: "Tool result" }],
isError: false,
timestamp: 0,
},
{
role: "user",
content: "Now do something else",
},
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
expect(contents).toHaveLength(4);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("user");
expect(contents[3].role).toBe("user");
const toolResponsePart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolResponse = asRecord(toolResponsePart);
expect(requireRecordProperty(toolResponse, "functionResponse").name).toBe("myTool");
expect(contents[3].role).toBe("user");
});
it("ensures function call comes after user turn, not after model turn", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Hello",
},
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi!" }]),
makeGoogleAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: {},
},
]),
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
expectConvertedRoles(contents, ["user", "model", "model", "user"]);
const toolCallPart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolCall = asRecord(toolCallPart);
expect(requireRecordProperty(toolCall, "functionCall").name).toBe("myTool");
});
it("strips tool call and response ids for google-gemini-cli", () => {
const model = makeGeminiCliModel("gemini-3-flash");
const context = {
messages: [
{
role: "user",
content: "Use a tool",
},
makeGeminiCliAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: { arg: "value" },
thoughtSignature: "dGVzdA==",
},
]),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "myTool",
content: [{ type: "text", text: "Tool result" }],
isError: false,
timestamp: 0,
},
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
const parts = contents.flatMap((content) => content.parts ?? []);
const toolCallPart = parts.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolResponsePart = parts.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolCall = asRecord(toolCallPart);
const toolResponse = asRecord(toolResponsePart);
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
});
it("serializes structured tool results into function responses", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "toolResult",
toolCallId: "call_1",
toolName: "session_status",
content: [{ type: "json", payload: { sessionKey: "current", status: "ok" } }],
isError: false,
timestamp: 0,
},
],
} as unknown as Context;
const contents = convertMessagesForTest(model, context);
const toolResponsePart = contents[0]?.parts?.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
expect(toolResponsePart).toBeDefined();
const toolResponse = requireRecordProperty(asRecord(toolResponsePart), "functionResponse");
expect(asRecord(toolResponse.response).output).toBe(
'{"type":"json","payload":{"sessionKey":"current","status":"ok"}}',
);
});
});