mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
062f88e3e3
* 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
404 lines
12 KiB
TypeScript
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"}}',
|
|
);
|
|
});
|
|
});
|