chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveDebugConfig } from "../debug";
|
||||
|
||||
describe("resolveDebugConfig", () => {
|
||||
it("returns all-off for undefined", () => {
|
||||
expect(resolveDebugConfig(undefined)).toEqual({
|
||||
enabled: false,
|
||||
events: false,
|
||||
lifecycle: false,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all-off for false", () => {
|
||||
expect(resolveDebugConfig(false)).toEqual({
|
||||
enabled: false,
|
||||
events: false,
|
||||
lifecycle: false,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns events + lifecycle on but verbose off for true (PII safety)", () => {
|
||||
expect(resolveDebugConfig(true)).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: true,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows explicit verbose opt-in via object shorthand", () => {
|
||||
expect(resolveDebugConfig({ verbose: true })).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: true,
|
||||
verbose: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows explicit verbose opt-in with events", () => {
|
||||
expect(resolveDebugConfig({ events: true, verbose: true })).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: true,
|
||||
verbose: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults events and lifecycle to true, verbose to false for empty object", () => {
|
||||
expect(resolveDebugConfig({})).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: true,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit events: true", () => {
|
||||
expect(resolveDebugConfig({ events: true })).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: true,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit events: false (lifecycle still defaults true)", () => {
|
||||
expect(resolveDebugConfig({ events: false })).toEqual({
|
||||
enabled: true,
|
||||
events: false,
|
||||
lifecycle: true,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit lifecycle: false (events still defaults true)", () => {
|
||||
expect(resolveDebugConfig({ lifecycle: false })).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: false,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets enabled to false when both events and lifecycle are false", () => {
|
||||
expect(resolveDebugConfig({ events: false, lifecycle: false })).toEqual({
|
||||
enabled: false,
|
||||
events: false,
|
||||
lifecycle: false,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps verbose to false when enabled is false (events and lifecycle both false)", () => {
|
||||
expect(
|
||||
resolveDebugConfig({ events: false, lifecycle: false, verbose: true }),
|
||||
).toEqual({
|
||||
enabled: false,
|
||||
events: false,
|
||||
lifecycle: false,
|
||||
verbose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed config: events true, lifecycle false, verbose true", () => {
|
||||
expect(
|
||||
resolveDebugConfig({ events: true, lifecycle: false, verbose: true }),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
events: true,
|
||||
lifecycle: false,
|
||||
verbose: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createLicenseContextValue } from "../index";
|
||||
import type { RuntimeLicenseStatus } from "../utils/types";
|
||||
|
||||
describe("createLicenseContextValue", () => {
|
||||
it("fails open when no status is known", () => {
|
||||
for (const status of [null, undefined] as const) {
|
||||
const ctx = createLicenseContextValue(status);
|
||||
expect(ctx.status).toBeNull();
|
||||
expect(ctx.license).toBeNull();
|
||||
expect(ctx.checkFeature("chat")).toBe(true);
|
||||
expect(ctx.getLimit("chat")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["valid", "none", "expiring", "unknown"] as RuntimeLicenseStatus[])(
|
||||
"enables features for %s status",
|
||||
(status) => {
|
||||
const ctx = createLicenseContextValue(status);
|
||||
expect(ctx.status).toBe(status);
|
||||
expect(ctx.checkFeature("chat")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["expired", "invalid"] as RuntimeLicenseStatus[])(
|
||||
"disables features for %s status",
|
||||
(status) => {
|
||||
const ctx = createLicenseContextValue(status);
|
||||
expect(ctx.status).toBe(status);
|
||||
expect(ctx.checkFeature("chat")).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expectTypeOf } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { type } from "arktype";
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
import type { InferSchemaOutput } from "../standard-schema";
|
||||
|
||||
describe("InferSchemaOutput type inference", () => {
|
||||
describe("Zod schemas", () => {
|
||||
it("infers output type from a Zod object schema", () => {
|
||||
const schema = z.object({
|
||||
city: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
city: string;
|
||||
count: number;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with optional fields", () => {
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number().optional(),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
name: string;
|
||||
age?: number | undefined;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with enums", () => {
|
||||
const schema = z.object({
|
||||
unit: z.enum(["celsius", "fahrenheit"]),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
unit: "celsius" | "fahrenheit";
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with nested objects", () => {
|
||||
const schema = z.object({
|
||||
user: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
user: { name: string; email: string };
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with arrays", () => {
|
||||
const schema = z.object({
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
tags: string[];
|
||||
}>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Valibot schemas", () => {
|
||||
it("infers output type from a Valibot object schema", () => {
|
||||
const schema = v.object({
|
||||
query: v.string(),
|
||||
limit: v.number(),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
query: string;
|
||||
limit: number;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with optional fields", () => {
|
||||
const schema = v.object({
|
||||
name: v.string(),
|
||||
age: v.optional(v.number()),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
name: string;
|
||||
age?: number | undefined;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with nested objects", () => {
|
||||
const schema = v.object({
|
||||
user: v.object({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
user: { name: string; email: string };
|
||||
}>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArkType schemas", () => {
|
||||
it("infers output type from an ArkType object schema", () => {
|
||||
const schema = type({
|
||||
query: "string",
|
||||
limit: "number",
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
query: string;
|
||||
limit: number;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("infers output type with optional fields", () => {
|
||||
const schema = type({
|
||||
name: "string",
|
||||
"age?": "number",
|
||||
});
|
||||
|
||||
expectTypeOf<InferSchemaOutput<typeof schema>>().toEqualTypeOf<{
|
||||
name: string;
|
||||
age?: number;
|
||||
}>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StandardSchemaV1 compatibility", () => {
|
||||
it("all schema types satisfy StandardSchemaV1", () => {
|
||||
expectTypeOf(
|
||||
z.object({ a: z.string() }),
|
||||
).toMatchTypeOf<StandardSchemaV1>();
|
||||
expectTypeOf(
|
||||
v.object({ a: v.string() }),
|
||||
).toMatchTypeOf<StandardSchemaV1>();
|
||||
expectTypeOf(type({ a: "string" })).toMatchTypeOf<StandardSchemaV1>();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { toStandardJsonSchema } from "@valibot/to-json-schema";
|
||||
import { type } from "arktype";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { schemaToJsonSchema } from "../standard-schema";
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
|
||||
describe("schemaToJsonSchema", () => {
|
||||
describe("Zod schemas (via injected zodToJsonSchema)", () => {
|
||||
it("converts a simple zod object schema", () => {
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const result = schemaToJsonSchema(schema, { zodToJsonSchema });
|
||||
|
||||
expect(result).toHaveProperty("type", "object");
|
||||
expect(result).toHaveProperty("properties.name.type", "string");
|
||||
expect(result).toHaveProperty("properties.age.type", "number");
|
||||
expect(result).toHaveProperty("required");
|
||||
expect(result.required).toContain("name");
|
||||
expect(result.required).toContain("age");
|
||||
});
|
||||
|
||||
it("converts a zod schema with optional fields", () => {
|
||||
const schema = z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]).optional(),
|
||||
});
|
||||
|
||||
const result = schemaToJsonSchema(schema, { zodToJsonSchema });
|
||||
|
||||
expect(result).toHaveProperty("type", "object");
|
||||
expect(result).toHaveProperty("properties.city.type", "string");
|
||||
expect(result.required).toContain("city");
|
||||
expect(result.required).not.toContain("units");
|
||||
});
|
||||
|
||||
it("has vendor 'zod' on ~standard", () => {
|
||||
const schema = z.object({ foo: z.string() });
|
||||
expect(schema["~standard"].vendor).toBe("zod");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Valibot schemas (via toStandardJsonSchema wrapper)", () => {
|
||||
it("has vendor 'valibot' on ~standard", () => {
|
||||
const schema = v.object({ name: v.string() });
|
||||
expect(schema["~standard"].vendor).toBe("valibot");
|
||||
});
|
||||
|
||||
it("satisfies StandardSchemaV1", () => {
|
||||
const schema = v.object({ name: v.string() });
|
||||
const _: StandardSchemaV1 = schema;
|
||||
expect(_["~standard"].version).toBe(1);
|
||||
});
|
||||
|
||||
it("raw valibot schema throws (no ~standard.jsonSchema)", () => {
|
||||
const schema = v.object({ name: v.string() });
|
||||
expect(() => schemaToJsonSchema(schema)).toThrow(
|
||||
/Cannot convert schema to JSON Schema/,
|
||||
);
|
||||
});
|
||||
|
||||
it("converts a valibot schema wrapped with toStandardJsonSchema", () => {
|
||||
const schema = toStandardJsonSchema(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
age: v.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = schemaToJsonSchema(schema);
|
||||
|
||||
expect(result).toHaveProperty("type", "object");
|
||||
expect(result).toHaveProperty("properties.name.type", "string");
|
||||
expect(result).toHaveProperty("properties.age.type", "number");
|
||||
expect(result.required).toContain("name");
|
||||
expect(result.required).toContain("age");
|
||||
});
|
||||
|
||||
it("converts a valibot schema with optional fields", () => {
|
||||
const schema = toStandardJsonSchema(
|
||||
v.object({
|
||||
city: v.string(),
|
||||
units: v.optional(v.picklist(["celsius", "fahrenheit"])),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = schemaToJsonSchema(schema);
|
||||
|
||||
expect(result).toHaveProperty("type", "object");
|
||||
expect(result).toHaveProperty("properties.city");
|
||||
expect(result.required).toContain("city");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArkType schemas (native ~standard.jsonSchema)", () => {
|
||||
it("has vendor 'arktype' on ~standard", () => {
|
||||
const schema = type({ name: "string" });
|
||||
expect(schema["~standard"].vendor).toBe("arktype");
|
||||
});
|
||||
|
||||
it("satisfies StandardSchemaV1", () => {
|
||||
const schema = type({ name: "string" });
|
||||
const _: StandardSchemaV1 = schema;
|
||||
expect(_["~standard"].version).toBe(1);
|
||||
});
|
||||
|
||||
it("natively implements ~standard.jsonSchema", () => {
|
||||
const schema = type({ name: "string" });
|
||||
const props = schema["~standard"];
|
||||
expect(props.jsonSchema).toBeDefined();
|
||||
expect(typeof props.jsonSchema.input).toBe("function");
|
||||
});
|
||||
|
||||
it("converts an arktype schema directly", () => {
|
||||
const schema = type({
|
||||
name: "string",
|
||||
age: "number",
|
||||
});
|
||||
|
||||
const result = schemaToJsonSchema(schema);
|
||||
|
||||
expect(result).toHaveProperty("type", "object");
|
||||
expect(result).toHaveProperty("properties.name.type", "string");
|
||||
expect(result).toHaveProperty("properties.age.type", "number");
|
||||
expect(result.required).toContain("name");
|
||||
expect(result.required).toContain("age");
|
||||
});
|
||||
|
||||
it("converts an arktype schema without needing zodToJsonSchema fallback", () => {
|
||||
const schema = type({ query: "string" });
|
||||
|
||||
// No zodToJsonSchema option needed — arktype uses Standard JSON Schema V1 natively
|
||||
const result = schemaToJsonSchema(schema);
|
||||
|
||||
expect(result).toHaveProperty("type", "object");
|
||||
expect(result).toHaveProperty("properties.query.type", "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Standard JSON Schema V1 protocol (mock)", () => {
|
||||
it("uses ~standard.jsonSchema.input() when present", () => {
|
||||
const mockSchema: StandardSchemaV1 & {
|
||||
"~standard": StandardSchemaV1["~standard"] & {
|
||||
jsonSchema: {
|
||||
input: (opts: { target: string }) => Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
} = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "mock-lib",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
jsonSchema: {
|
||||
input: (opts: { target: string }) => ({
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
required: ["query"],
|
||||
$generatedBy: "mock-lib",
|
||||
$target: opts.target,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = schemaToJsonSchema(mockSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
$generatedBy: "mock-lib",
|
||||
$target: "draft-07",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers ~standard.jsonSchema over zodToJsonSchema fallback", () => {
|
||||
const mockSchema = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "zod",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
jsonSchema: {
|
||||
input: () => ({
|
||||
type: "object",
|
||||
properties: { fromJsonSchema: { type: "boolean" } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const zodFallback = () => ({
|
||||
type: "object",
|
||||
properties: { fromZodFallback: { type: "boolean" } },
|
||||
});
|
||||
|
||||
const result = schemaToJsonSchema(mockSchema, {
|
||||
zodToJsonSchema: zodFallback,
|
||||
});
|
||||
|
||||
// Should use ~standard.jsonSchema, not the zod fallback
|
||||
expect(result).toHaveProperty("properties.fromJsonSchema");
|
||||
expect(result).not.toHaveProperty("properties.fromZodFallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zod v4 schemas (via toJSONSchema method)", () => {
|
||||
it("calls toJSONSchema() when the method exists on the schema", () => {
|
||||
const expectedOutput = {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const mockZod4Schema: StandardSchemaV1 = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "zod",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
},
|
||||
toJSONSchema: () => expectedOutput,
|
||||
} as any;
|
||||
|
||||
const result = schemaToJsonSchema(mockZod4Schema);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("uses toJSONSchema() even without zodToJsonSchema option", () => {
|
||||
const mockZod4Schema: StandardSchemaV1 = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "zod",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
},
|
||||
toJSONSchema: () => ({
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// No options passed — toJSONSchema() should still work
|
||||
const result = schemaToJsonSchema(mockZod4Schema);
|
||||
expect(result).toHaveProperty("properties.city.type", "string");
|
||||
});
|
||||
|
||||
it("prefers toJSONSchema() over zodToJsonSchema fallback for Zod v4", () => {
|
||||
const mockZod4Schema: StandardSchemaV1 = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "zod",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
},
|
||||
toJSONSchema: () => ({
|
||||
type: "object",
|
||||
properties: { fromNative: { type: "boolean" } },
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const zodFallback = () => ({
|
||||
type: "object",
|
||||
properties: { fromFallback: { type: "boolean" } },
|
||||
});
|
||||
|
||||
const result = schemaToJsonSchema(mockZod4Schema, {
|
||||
zodToJsonSchema: zodFallback,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("properties.fromNative");
|
||||
expect(result).not.toHaveProperty("properties.fromFallback");
|
||||
});
|
||||
|
||||
it("prefers ~standard.jsonSchema over toJSONSchema()", () => {
|
||||
const mockSchema = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "zod",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
jsonSchema: {
|
||||
input: () => ({
|
||||
type: "object",
|
||||
properties: { fromStandard: { type: "boolean" } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
toJSONSchema: () => ({
|
||||
type: "object",
|
||||
properties: { fromToJSONSchema: { type: "boolean" } },
|
||||
}),
|
||||
};
|
||||
|
||||
const result = schemaToJsonSchema(mockSchema);
|
||||
|
||||
// Standard JSON Schema V1 should take priority
|
||||
expect(result).toHaveProperty("properties.fromStandard");
|
||||
expect(result).not.toHaveProperty("properties.fromToJSONSchema");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("throws when schema has no jsonSchema support and no zodToJsonSchema", () => {
|
||||
const mockSchema: StandardSchemaV1 = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "unknown-lib",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => schemaToJsonSchema(mockSchema)).toThrow(
|
||||
/Cannot convert schema to JSON Schema/,
|
||||
);
|
||||
expect(() => schemaToJsonSchema(mockSchema)).toThrow(/unknown-lib/);
|
||||
});
|
||||
|
||||
it("throws for non-zod vendor when zodToJsonSchema is not provided", () => {
|
||||
const mockSchema: StandardSchemaV1 = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "some-other-lib",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => schemaToJsonSchema(mockSchema)).toThrow(
|
||||
/no zodToJsonSchema fallback/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses zodToJsonSchema fallback for zod vendor schemas", () => {
|
||||
const mockZodSchema: StandardSchemaV1 = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "zod",
|
||||
validate: (value: unknown) => ({ value }),
|
||||
},
|
||||
};
|
||||
|
||||
const fallback = () => ({
|
||||
type: "object",
|
||||
properties: { test: { type: "string" } },
|
||||
});
|
||||
|
||||
const result = schemaToJsonSchema(mockZodSchema, {
|
||||
zodToJsonSchema: fallback,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "object",
|
||||
properties: { test: { type: "string" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Regression tests proving that `schemaToJsonSchema` produces identical output
|
||||
* to a direct `zodToJsonSchema` call for all common Zod patterns.
|
||||
*
|
||||
* These tests exist to guarantee that the Standard Schema migration did not
|
||||
* change the JSON Schema output for existing Zod users.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { schemaToJsonSchema } from "../standard-schema";
|
||||
|
||||
/**
|
||||
* Helper: call zodToJsonSchema the same way the old code path did, then
|
||||
* compare with the new schemaToJsonSchema path.
|
||||
*/
|
||||
function expectIdenticalOutput(schema: z.ZodTypeAny) {
|
||||
const direct = zodToJsonSchema(schema, { $refStrategy: "none" });
|
||||
const migrated = schemaToJsonSchema(schema, { zodToJsonSchema });
|
||||
expect(migrated).toEqual(direct);
|
||||
}
|
||||
|
||||
describe("Zod regression — schemaToJsonSchema produces identical output to direct zodToJsonSchema", () => {
|
||||
describe("simple object schemas", () => {
|
||||
it("required string and number fields", () => {
|
||||
expectIdenticalOutput(z.object({ name: z.string(), age: z.number() }));
|
||||
});
|
||||
|
||||
it("optional fields", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(["celsius", "fahrenheit"]).optional(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("empty object", () => {
|
||||
expectIdenticalOutput(z.object({}));
|
||||
});
|
||||
|
||||
it("object with default values", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
page: z.number().default(1),
|
||||
perPage: z.number().default(20),
|
||||
query: z.string(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested objects", () => {
|
||||
it("single level nesting", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
user: z.object({ name: z.string(), email: z.string() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deep nesting", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
level1: z.object({
|
||||
level2: z.object({
|
||||
level3: z.object({
|
||||
value: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrays", () => {
|
||||
it("string array", () => {
|
||||
expectIdenticalOutput(z.object({ tags: z.array(z.string()) }));
|
||||
});
|
||||
|
||||
it("object array", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
items: z.array(z.object({ id: z.number(), label: z.string() })),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("nested arrays", () => {
|
||||
expectIdenticalOutput(z.object({ matrix: z.array(z.array(z.number())) }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("enums and literals", () => {
|
||||
it("z.enum", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({ status: z.enum(["active", "inactive", "pending"]) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("z.literal string", () => {
|
||||
expectIdenticalOutput(z.object({ type: z.literal("user") }));
|
||||
});
|
||||
|
||||
it("z.literal number", () => {
|
||||
expectIdenticalOutput(z.object({ version: z.literal(2) }));
|
||||
});
|
||||
|
||||
it("z.nativeEnum", () => {
|
||||
enum Color {
|
||||
Red = "red",
|
||||
Green = "green",
|
||||
Blue = "blue",
|
||||
}
|
||||
expectIdenticalOutput(z.object({ color: z.nativeEnum(Color) }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("unions and discriminated unions", () => {
|
||||
it("z.union of primitives", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({ value: z.union([z.string(), z.number()]) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("z.union of objects", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
result: z.union([
|
||||
z.object({ ok: z.literal(true), data: z.string() }),
|
||||
z.object({ ok: z.literal(false), error: z.string() }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("z.discriminatedUnion", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
event: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("click"),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
z.object({ type: z.literal("keypress"), key: z.string() }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nullable and nullish", () => {
|
||||
it("z.nullable", () => {
|
||||
expectIdenticalOutput(z.object({ bio: z.string().nullable() }));
|
||||
});
|
||||
|
||||
it("z.nullish (optional + nullable)", () => {
|
||||
expectIdenticalOutput(z.object({ nickname: z.string().nullish() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("records and tuples", () => {
|
||||
it("z.record", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({ metadata: z.record(z.string(), z.unknown()) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("z.tuple", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({ point: z.tuple([z.number(), z.number()]) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersections", () => {
|
||||
it("z.intersection", () => {
|
||||
expectIdenticalOutput(
|
||||
z.intersection(
|
||||
z.object({ id: z.number() }),
|
||||
z.object({ name: z.string() }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("string constraints", () => {
|
||||
it("z.string with min/max", () => {
|
||||
expectIdenticalOutput(z.object({ username: z.string().min(3).max(20) }));
|
||||
});
|
||||
|
||||
it("z.string with email", () => {
|
||||
expectIdenticalOutput(z.object({ email: z.string().email() }));
|
||||
});
|
||||
|
||||
it("z.string with url", () => {
|
||||
expectIdenticalOutput(z.object({ website: z.string().url() }));
|
||||
});
|
||||
|
||||
it("z.string with regex", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({ code: z.string().regex(/^[A-Z]{3}-\d{4}$/) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("number constraints", () => {
|
||||
it("z.number with min/max", () => {
|
||||
expectIdenticalOutput(z.object({ score: z.number().min(0).max(100) }));
|
||||
});
|
||||
|
||||
it("z.number.int", () => {
|
||||
expectIdenticalOutput(z.object({ count: z.number().int() }));
|
||||
});
|
||||
|
||||
it("z.number.positive", () => {
|
||||
expectIdenticalOutput(z.object({ amount: z.number().positive() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("catchall and passthrough", () => {
|
||||
it("z.object with catchall", () => {
|
||||
expectIdenticalOutput(z.object({}).catchall(z.string()));
|
||||
});
|
||||
|
||||
it("z.object with passthrough", () => {
|
||||
expectIdenticalOutput(z.object({ id: z.number() }).passthrough());
|
||||
});
|
||||
});
|
||||
|
||||
describe("z.any and z.unknown", () => {
|
||||
it("z.any", () => {
|
||||
expectIdenticalOutput(z.any());
|
||||
});
|
||||
|
||||
it("z.unknown", () => {
|
||||
expectIdenticalOutput(z.unknown());
|
||||
});
|
||||
|
||||
it("object with z.any field", () => {
|
||||
expectIdenticalOutput(z.object({ data: z.any() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex real-world schemas", () => {
|
||||
it("tool-like schema with multiple field types", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
query: z.string().describe("Search query"),
|
||||
filters: z
|
||||
.object({
|
||||
category: z.enum(["books", "movies", "music"]).optional(),
|
||||
minRating: z.number().min(0).max(5).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
pagination: z
|
||||
.object({
|
||||
page: z.number().int().positive().default(1),
|
||||
perPage: z.number().int().positive().default(20),
|
||||
})
|
||||
.optional(),
|
||||
sortBy: z.enum(["relevance", "date", "rating"]).default("relevance"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("CopilotKit-like weather tool schema", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
city: z.string().describe("The city to get weather for"),
|
||||
units: z
|
||||
.enum(["celsius", "fahrenheit"])
|
||||
.optional()
|
||||
.describe("Temperature units"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("agent-like action schema with nested results", () => {
|
||||
expectIdenticalOutput(
|
||||
z.object({
|
||||
action: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
maxResults: z.number().int().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("navigate"),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("execute"),
|
||||
code: z.string(),
|
||||
language: z.enum(["javascript", "python", "shell"]),
|
||||
}),
|
||||
]),
|
||||
context: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("described schemas (common in tool definitions)", () => {
|
||||
it("preserves .describe() metadata", () => {
|
||||
expectIdenticalOutput(
|
||||
z
|
||||
.object({
|
||||
city: z.string().describe("The city name"),
|
||||
count: z.number().describe("Number of results"),
|
||||
})
|
||||
.describe("Weather tool parameters"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Default A2UI generation and design guideline prompts.
|
||||
*
|
||||
* These are the canonical prompt fragments that instruct an LLM how to call
|
||||
* the render_a2ui tool, how to bind data, and how to style surfaces.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generation guidelines — protocol rules, tool arguments, path rules,
|
||||
* data model format, and form/two-way-binding instructions.
|
||||
*/
|
||||
export const A2UI_DEFAULT_GENERATION_GUIDELINES = `\
|
||||
Generate A2UI v0.9 JSON.
|
||||
|
||||
## A2UI Protocol Instructions
|
||||
|
||||
A2UI (Agent to UI) is a protocol for rendering rich UI surfaces from agent responses.
|
||||
|
||||
CRITICAL: You MUST call the render_a2ui tool with ALL of these arguments:
|
||||
- surfaceId: A unique ID for the surface (e.g. "product-comparison")
|
||||
- components: REQUIRED — the A2UI component array. NEVER omit this. Only use
|
||||
components listed in the Available Components schema provided as context.
|
||||
- data: OPTIONAL — a JSON object written to the root of the surface data model.
|
||||
Use for pre-filling form values or providing data for path-bound components.
|
||||
- every component must have the "component" field specifying the component type.
|
||||
ONLY use component names from the Available Components schema — do NOT invent
|
||||
component names or use names not in the schema.
|
||||
|
||||
COMPONENT ID RULES:
|
||||
- Exactly one component MUST have id="root". This is the surface's entry
|
||||
point — the renderer begins at "root" and walks the child/children tree
|
||||
from there. Every other component must be reachable from "root". If no
|
||||
component has id="root", the surface renders an empty loading placeholder
|
||||
and none of your components will be shown.
|
||||
- Every component ID must be unique within the surface.
|
||||
- A component MUST NOT reference itself as child/children. This causes a
|
||||
circular dependency error. For example, if a component has id="avatar",
|
||||
its child must be a DIFFERENT id (e.g. "avatar-img"), never "avatar".
|
||||
- The child/children tree must be a DAG — no cycles allowed.
|
||||
|
||||
REPEATING CONTENT (TEMPLATES):
|
||||
To repeat a component for each item in an array, use the structural children format:
|
||||
children: { componentId: "card-id", path: "/items" }
|
||||
This tells the renderer to create one instance of "card-id" per item in the "/items" array.
|
||||
|
||||
PATH RULES FOR TEMPLATES:
|
||||
Components inside a repeating template use RELATIVE paths (no leading slash).
|
||||
The path is resolved relative to each array item automatically.
|
||||
If a container has children: { componentId: "card", path: "/items" } and each item
|
||||
has a key "name", use { "path": "name" } (NO leading slash — relative to item).
|
||||
CRITICAL: Do NOT use "/name" (absolute) inside templates — use "name" (relative).
|
||||
The container's path ("/items") uses a leading slash (absolute), but all
|
||||
components INSIDE the template use paths WITHOUT leading slash.
|
||||
|
||||
COMPONENT VALUES — DEFAULT RULE:
|
||||
Use inline literal values for ALL component properties. Pass strings, numbers,
|
||||
arrays, and objects directly on the component. Do NOT use { "path": "..." }
|
||||
objects unless the property's schema explicitly allows it (see exception below).
|
||||
CRITICAL: USING { "path": "..." } ON A PROPERTY THAT DOES NOT DECLARE PATH
|
||||
SUPPORT IN ITS SCHEMA WILL CAUSE A RUNTIME CRASH AND BREAK THE ENTIRE UI.
|
||||
ALWAYS CHECK THE COMPONENT SCHEMA FIRST — IF THE PROPERTY ONLY ACCEPTS A
|
||||
PLAIN TYPE, YOU MUST USE A LITERAL VALUE.
|
||||
VERY IMPORTANT: THE APPLICATION WILL BREAK IF YOU DO NOT FOLLOW THIS RULE!
|
||||
|
||||
For example, a chart's "data" must always be an inline array:
|
||||
"data": [{"label": "Jan", "value": 100}, {"label": "Feb", "value": 200}]
|
||||
A metric's "value" must always be an inline string:
|
||||
"value": "$1,200"
|
||||
|
||||
PATH BINDING EXCEPTION — SCHEMA-DRIVEN:
|
||||
A few properties accept { "path": "/some/path" } as an alternative to a literal
|
||||
value. You can identify these in the Available Components schema: the property
|
||||
will list BOTH a literal type AND an object-with-path option. If a property only
|
||||
shows a single type (string, number, array, etc.), it does NOT support path
|
||||
binding — use a literal value only.
|
||||
|
||||
Path binding is typically used for editable form inputs so the client can write
|
||||
user input back to the data model. When building forms:
|
||||
- Bind input "value" to a data model path: "value": { "path": "/form/name" }
|
||||
- Pre-fill via the "data" tool argument: "data": { "form": { "name": "Alice" } }
|
||||
- Capture values on submit via button action context:
|
||||
"action": { "event": { "name": "submit", "context": { "name": { "path": "/form/name" } } } }
|
||||
|
||||
REPEATING CONTENT uses a structural children format (not the same as value binding):
|
||||
children: { componentId: "card-id", path: "/items" }
|
||||
Components inside templates use RELATIVE paths (no leading slash): { "path": "name" }.`;
|
||||
|
||||
/**
|
||||
* Design guidelines — visual design rules, component hierarchy tips,
|
||||
* and action handler patterns.
|
||||
*/
|
||||
export const A2UI_DEFAULT_DESIGN_GUIDELINES = `\
|
||||
Create polished, visually appealing interfaces. ONLY use components listed in the
|
||||
Available Components schema — do NOT use component names that are not in the schema.
|
||||
|
||||
Design principles:
|
||||
- Create clear visual hierarchy within cards and layouts.
|
||||
- Keep cards clean — avoid clutter. Whitespace is good.
|
||||
- Use consistent surfaceIds (lowercase, hyphenated).
|
||||
- NEVER use the same ID for a component and its child — this creates a
|
||||
circular dependency. E.g. if id="avatar", child must NOT be "avatar".
|
||||
- For side-by-side comparisons, use a container with structural children
|
||||
(children: { componentId, path }) to repeat a card template per data item.
|
||||
- Include images when relevant (logos, icons, product photos):
|
||||
- Prefer company logos via Google favicons: https://www.google.com/s2/favicons?domain=example.com&sz=128
|
||||
- Do NOT invent Unsplash photo-IDs — they will 404. Only use real, known URLs.
|
||||
- For buttons: action MUST use this exact nested format:
|
||||
"action": { "event": { "name": "myAction", "context": { "key": "value" } } }
|
||||
The "event" key holds an OBJECT with "name" (required) and "context" (optional).
|
||||
Do NOT use a flat format like {"event": "name"} — "event" must be an object.
|
||||
- For forms: check the component schema — if an input's "value" property
|
||||
supports path binding, use it for editable fields. The submit button's
|
||||
action context should reference the same paths to capture user input.
|
||||
|
||||
Use the SAME surfaceId as the main surface. Match action names to button action event names.`;
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
getModalityFromMimeType,
|
||||
formatFileSize,
|
||||
exceedsMaxSize,
|
||||
matchesAcceptFilter,
|
||||
} from "../utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockFile(size: number): File {
|
||||
return { size } as File;
|
||||
}
|
||||
|
||||
function mockFileWithType(type: string, name = "test"): File {
|
||||
return { type, name } as File;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getModalityFromMimeType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getModalityFromMimeType", () => {
|
||||
it('returns "image" for image/png', () => {
|
||||
expect(getModalityFromMimeType("image/png")).toBe("image");
|
||||
});
|
||||
|
||||
it('returns "image" for image/jpeg', () => {
|
||||
expect(getModalityFromMimeType("image/jpeg")).toBe("image");
|
||||
});
|
||||
|
||||
it('returns "audio" for audio/mp3', () => {
|
||||
expect(getModalityFromMimeType("audio/mp3")).toBe("audio");
|
||||
});
|
||||
|
||||
it('returns "audio" for audio/wav', () => {
|
||||
expect(getModalityFromMimeType("audio/wav")).toBe("audio");
|
||||
});
|
||||
|
||||
it('returns "video" for video/mp4', () => {
|
||||
expect(getModalityFromMimeType("video/mp4")).toBe("video");
|
||||
});
|
||||
|
||||
it('returns "video" for video/webm', () => {
|
||||
expect(getModalityFromMimeType("video/webm")).toBe("video");
|
||||
});
|
||||
|
||||
it('returns "document" for application/pdf', () => {
|
||||
expect(getModalityFromMimeType("application/pdf")).toBe("document");
|
||||
});
|
||||
|
||||
it('returns "document" for text/plain', () => {
|
||||
expect(getModalityFromMimeType("text/plain")).toBe("document");
|
||||
});
|
||||
|
||||
it('returns "document" for empty string (fallback)', () => {
|
||||
expect(getModalityFromMimeType("")).toBe("document");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatFileSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it('formats 0 bytes as "0 B"', () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
});
|
||||
|
||||
it('formats 512 bytes as "512 B"', () => {
|
||||
expect(formatFileSize(512)).toBe("512 B");
|
||||
});
|
||||
|
||||
it('formats 1023 bytes as "1023 B"', () => {
|
||||
expect(formatFileSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it('formats 1024 bytes as "1.0 KB"', () => {
|
||||
expect(formatFileSize(1024)).toBe("1.0 KB");
|
||||
});
|
||||
|
||||
it('formats 1536 bytes as "1.5 KB"', () => {
|
||||
expect(formatFileSize(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it('formats 1048575 bytes as "1024.0 KB" (one byte under 1 MB)', () => {
|
||||
expect(formatFileSize(1048575)).toBe("1024.0 KB");
|
||||
});
|
||||
|
||||
it('formats 1048576 bytes as "1.0 MB"', () => {
|
||||
expect(formatFileSize(1048576)).toBe("1.0 MB");
|
||||
});
|
||||
|
||||
it('formats 10485760 bytes as "10.0 MB"', () => {
|
||||
expect(formatFileSize(10485760)).toBe("10.0 MB");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exceedsMaxSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MB_20 = 20 * 1024 * 1024;
|
||||
|
||||
describe("exceedsMaxSize", () => {
|
||||
it("returns false for a file exactly at the 20 MB default limit", () => {
|
||||
expect(exceedsMaxSize(mockFile(MB_20))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a file one byte over the 20 MB default limit", () => {
|
||||
expect(exceedsMaxSize(mockFile(MB_20 + 1))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a file well under the default limit", () => {
|
||||
expect(exceedsMaxSize(mockFile(1024))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when file exceeds a custom maxSize", () => {
|
||||
const MB_5 = 5 * 1024 * 1024;
|
||||
const MB_10 = 10 * 1024 * 1024;
|
||||
expect(exceedsMaxSize(mockFile(MB_10), MB_5)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// matchesAcceptFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("matchesAcceptFilter", () => {
|
||||
it('returns true for accept "*/*" regardless of file type', () => {
|
||||
expect(matchesAcceptFilter(mockFileWithType("image/png"), "*/*")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for empty accept string (accept all)", () => {
|
||||
expect(matchesAcceptFilter(mockFileWithType("image/png"), "")).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when accept "image/*" matches "image/png"', () => {
|
||||
expect(matchesAcceptFilter(mockFileWithType("image/png"), "image/*")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when accept "image/*" rejects "audio/mp3"', () => {
|
||||
expect(matchesAcceptFilter(mockFileWithType("audio/mp3"), "image/*")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for exact match accept "application/pdf"', () => {
|
||||
expect(
|
||||
matchesAcceptFilter(
|
||||
mockFileWithType("application/pdf"),
|
||||
"application/pdf",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when exact accept "application/pdf" rejects "image/png"', () => {
|
||||
expect(
|
||||
matchesAcceptFilter(mockFileWithType("image/png"), "application/pdf"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for comma-separated accept "image/*,application/pdf" with "image/jpeg"', () => {
|
||||
expect(
|
||||
matchesAcceptFilter(
|
||||
mockFileWithType("image/jpeg"),
|
||||
"image/*,application/pdf",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for comma-separated accept "image/*,application/pdf" with "application/pdf"', () => {
|
||||
expect(
|
||||
matchesAcceptFilter(
|
||||
mockFileWithType("application/pdf"),
|
||||
"image/*,application/pdf",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("handles whitespace in comma-separated accept values", () => {
|
||||
expect(
|
||||
matchesAcceptFilter(
|
||||
mockFileWithType("application/pdf"),
|
||||
"image/* , application/pdf",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty file type against a specific filter", () => {
|
||||
expect(matchesAcceptFilter(mockFileWithType(""), "image/*")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
AttachmentsConfig,
|
||||
AttachmentUploadResult,
|
||||
AttachmentUploadError,
|
||||
AttachmentUploadErrorReason,
|
||||
Attachment,
|
||||
AttachmentModality,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
getModalityFromMimeType,
|
||||
formatFileSize,
|
||||
exceedsMaxSize,
|
||||
readFileAsBase64,
|
||||
generateVideoThumbnail,
|
||||
matchesAcceptFilter,
|
||||
getSourceUrl,
|
||||
getDocumentIcon,
|
||||
} from "./utils";
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
InputContentDataSource,
|
||||
InputContentUrlSource,
|
||||
} from "@ag-ui/core";
|
||||
|
||||
export interface AttachmentUploadDataResult {
|
||||
type: "data";
|
||||
value: string;
|
||||
mimeType: string;
|
||||
/** Custom metadata to include in the InputContent part (merged with auto-generated metadata like filename). */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AttachmentUploadUrlResult {
|
||||
type: "url";
|
||||
value: string;
|
||||
mimeType?: string;
|
||||
/** Custom metadata to include in the InputContent part (merged with auto-generated metadata like filename). */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AttachmentUploadResult =
|
||||
| AttachmentUploadDataResult
|
||||
| AttachmentUploadUrlResult;
|
||||
|
||||
export type AttachmentUploadErrorReason =
|
||||
| "file-too-large"
|
||||
| "invalid-type"
|
||||
| "upload-failed";
|
||||
|
||||
export interface AttachmentUploadError {
|
||||
/** Why the upload failed. */
|
||||
reason: AttachmentUploadErrorReason;
|
||||
/** The file that failed to upload. */
|
||||
file: File;
|
||||
/** Human-readable error message. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AttachmentsConfig {
|
||||
/** Enable file attachments in the chat input */
|
||||
enabled: boolean;
|
||||
/** MIME type filter for the file input, default all files */
|
||||
accept?: string;
|
||||
/** Maximum file size in bytes, default 20MB (20 * 1024 * 1024) */
|
||||
maxSize?: number;
|
||||
/** Custom upload handler. Return an InputContentSource with optional metadata. */
|
||||
onUpload?: (
|
||||
file: File,
|
||||
) => AttachmentUploadResult | Promise<AttachmentUploadResult>;
|
||||
/** Called when an attachment fails validation or upload. Use this to show a toast or inline error. */
|
||||
onUploadFailed?: (error: AttachmentUploadError) => void;
|
||||
}
|
||||
|
||||
export type AttachmentModality = "image" | "audio" | "video" | "document";
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
type: AttachmentModality;
|
||||
source: InputContentDataSource | InputContentUrlSource;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
status: "uploading" | "ready";
|
||||
thumbnail?: string;
|
||||
/** Custom metadata from onUpload, included in the InputContent part. */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { AttachmentModality } from "./types";
|
||||
import type { InputContentSource } from "../types/message";
|
||||
|
||||
const DEFAULT_MAX_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
/**
|
||||
* Derive the attachment modality from a MIME type string.
|
||||
*/
|
||||
export function getModalityFromMimeType(mimeType: string): AttachmentModality {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
return "document";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a byte count as a human-readable file size string.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exceeds the maximum allowed size.
|
||||
*/
|
||||
export function exceedsMaxSize(
|
||||
file: File,
|
||||
maxSize: number = DEFAULT_MAX_SIZE,
|
||||
): boolean {
|
||||
return file.size > maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a File as a base64 string (without the data URL prefix).
|
||||
*/
|
||||
export function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
const base64 = result?.split(",")[1];
|
||||
if (base64) {
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a thumbnail data URL from a video file by capturing a frame near the start (at 0.1s).
|
||||
* Returns undefined if thumbnail generation fails or if called outside a browser environment.
|
||||
*/
|
||||
export function generateVideoThumbnail(
|
||||
file: File,
|
||||
): Promise<string | undefined> {
|
||||
if (typeof document === "undefined") {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const video = document.createElement("video");
|
||||
const canvas = document.createElement("canvas");
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
const cleanup = (result: string | undefined) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[CopilotKit] generateVideoThumbnail: timed out for file "${file.name}"`,
|
||||
);
|
||||
cleanup(undefined);
|
||||
}, 10000);
|
||||
|
||||
video.preload = "metadata";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
video.onloadeddata = () => {
|
||||
video.currentTime = 0.1;
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
clearTimeout(timeout);
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0);
|
||||
const thumbnail = canvas.toDataURL("image/jpeg", 0.7);
|
||||
cleanup(thumbnail);
|
||||
} else {
|
||||
console.warn(
|
||||
"[CopilotKit] generateVideoThumbnail: could not get 2d canvas context",
|
||||
);
|
||||
cleanup(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
console.warn(
|
||||
`[CopilotKit] generateVideoThumbnail: video element error for file "${file.name}"`,
|
||||
);
|
||||
cleanup(undefined);
|
||||
};
|
||||
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file's MIME type matches an accept filter string.
|
||||
* Handles file extensions (e.g. ".pdf"), MIME wildcards ("image/*"), and comma-separated lists.
|
||||
*/
|
||||
export function matchesAcceptFilter(file: File, accept: string): boolean {
|
||||
if (!accept || accept === "*/*") return true;
|
||||
|
||||
const filters = accept.split(",").map((f) => f.trim());
|
||||
return filters.some((filter) => {
|
||||
if (filter.startsWith(".")) {
|
||||
return (file.name ?? "").toLowerCase().endsWith(filter.toLowerCase());
|
||||
}
|
||||
if (filter.endsWith("/*")) {
|
||||
const prefix = filter.slice(0, -2);
|
||||
return file.type.startsWith(prefix + "/");
|
||||
}
|
||||
return file.type === filter;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an InputContentSource to a usable URL string.
|
||||
* For data sources, returns a base64 data URL; for URL sources, returns the URL directly.
|
||||
*/
|
||||
export function getSourceUrl(source: InputContentSource): string {
|
||||
if (source.type === "url") {
|
||||
return source.value;
|
||||
}
|
||||
return `data:${source.mimeType};base64,${source.value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a short human-readable label for a document MIME type (e.g. "PDF", "DOC").
|
||||
*/
|
||||
export function getDocumentIcon(mimeType: string): string {
|
||||
if (mimeType.includes("pdf")) return "PDF";
|
||||
if (mimeType.includes("sheet") || mimeType.includes("excel")) return "XLS";
|
||||
if (mimeType.includes("presentation") || mimeType.includes("powerpoint"))
|
||||
return "PPT";
|
||||
if (mimeType.includes("word") || mimeType.includes("document")) return "DOC";
|
||||
if (mimeType.includes("text/")) return "TXT";
|
||||
return "FILE";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const COPILOT_CLOUD_API_URL = "https://api.cloud.copilotkit.ai";
|
||||
export const COPILOT_CLOUD_VERSION = "v1";
|
||||
export const COPILOT_CLOUD_CHAT_URL = `${COPILOT_CLOUD_API_URL}/copilotkit/${COPILOT_CLOUD_VERSION}`;
|
||||
export const COPILOT_CLOUD_PUBLIC_API_KEY_HEADER =
|
||||
"X-CopilotCloud-Public-Api-Key";
|
||||
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
|
||||
/** Phoenix channel event name used for all AG-UI events. */
|
||||
export const AG_UI_CHANNEL_EVENT = "ag-ui";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { BaseEvent } from "@ag-ui/client";
|
||||
|
||||
export interface DebugEventEnvelope {
|
||||
timestamp: number;
|
||||
agentId: string;
|
||||
threadId: string;
|
||||
runId: string;
|
||||
event: BaseEvent;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Granular debug configuration for CopilotKit runtime and client.
|
||||
* Pass `true` to enable events + lifecycle logging (but NOT verbose payloads),
|
||||
* or an object for granular control including `verbose: true` for full payloads.
|
||||
*/
|
||||
export type DebugConfig =
|
||||
| boolean
|
||||
| {
|
||||
/** Log every event emitted/received. Default: true */
|
||||
events?: boolean;
|
||||
/** Log request/run lifecycle. Default: true */
|
||||
lifecycle?: boolean;
|
||||
/** Log full event payloads instead of summaries. Default: false — must be explicitly opted in */
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
/** Normalized debug configuration — all fields resolved to booleans. */
|
||||
export interface ResolvedDebugConfig {
|
||||
enabled: boolean;
|
||||
events: boolean;
|
||||
lifecycle: boolean;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
/** The all-off config used when debug is falsy. */
|
||||
const DEBUG_OFF: ResolvedDebugConfig = {
|
||||
enabled: false,
|
||||
events: false,
|
||||
lifecycle: false,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a DebugConfig value into a ResolvedDebugConfig.
|
||||
*
|
||||
* - `false` / `undefined` → all off
|
||||
* - `true` → events + lifecycle on, verbose off (no PII in logs)
|
||||
* - object → merges with defaults (events: true, lifecycle: true, verbose: false)
|
||||
*/
|
||||
export function resolveDebugConfig(
|
||||
debug: DebugConfig | undefined,
|
||||
): ResolvedDebugConfig {
|
||||
if (!debug) return DEBUG_OFF;
|
||||
|
||||
if (debug === true) {
|
||||
return { enabled: true, events: true, lifecycle: true, verbose: false };
|
||||
}
|
||||
|
||||
const events = debug.events ?? true;
|
||||
const lifecycle = debug.lifecycle ?? true;
|
||||
const enabled = events || lifecycle;
|
||||
const verbose = enabled && (debug.verbose ?? false);
|
||||
|
||||
return { enabled, events, lifecycle, verbose };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { BaseEvent, EventType, RunErrorEvent } from "@ag-ui/client";
|
||||
import { randomUUID } from "./utils";
|
||||
|
||||
interface FinalizeRunOptions {
|
||||
stopRequested?: boolean;
|
||||
interruptionMessage?: string;
|
||||
}
|
||||
|
||||
const defaultStopMessage = "Run stopped by user";
|
||||
const defaultAbruptEndMessage = "Run ended without emitting a terminal event";
|
||||
|
||||
export function finalizeRunEvents(
|
||||
events: BaseEvent[],
|
||||
options: FinalizeRunOptions = {},
|
||||
): BaseEvent[] {
|
||||
const { stopRequested = false, interruptionMessage } = options;
|
||||
|
||||
const resolvedStopMessage = interruptionMessage ?? defaultStopMessage;
|
||||
const resolvedAbruptMessage =
|
||||
interruptionMessage && interruptionMessage !== defaultStopMessage
|
||||
? interruptionMessage
|
||||
: defaultAbruptEndMessage;
|
||||
|
||||
const appended: BaseEvent[] = [];
|
||||
|
||||
const openMessageIds = new Set<string>();
|
||||
const openToolCalls = new Map<
|
||||
string,
|
||||
{
|
||||
hasEnd: boolean;
|
||||
hasResult: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case EventType.TEXT_MESSAGE_START: {
|
||||
const messageId = (event as { messageId?: string }).messageId;
|
||||
if (typeof messageId === "string") {
|
||||
openMessageIds.add(messageId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EventType.TEXT_MESSAGE_END: {
|
||||
const messageId = (event as { messageId?: string }).messageId;
|
||||
if (typeof messageId === "string") {
|
||||
openMessageIds.delete(messageId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EventType.TOOL_CALL_START: {
|
||||
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
||||
if (typeof toolCallId === "string") {
|
||||
openToolCalls.set(toolCallId, {
|
||||
hasEnd: false,
|
||||
hasResult: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EventType.TOOL_CALL_END: {
|
||||
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
||||
const info = toolCallId ? openToolCalls.get(toolCallId) : undefined;
|
||||
if (info) {
|
||||
info.hasEnd = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EventType.TOOL_CALL_RESULT: {
|
||||
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
||||
const info = toolCallId ? openToolCalls.get(toolCallId) : undefined;
|
||||
if (info) {
|
||||
info.hasResult = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRunFinished = events.some(
|
||||
(event) => event.type === EventType.RUN_FINISHED,
|
||||
);
|
||||
const hasRunError = events.some(
|
||||
(event) => event.type === EventType.RUN_ERROR,
|
||||
);
|
||||
const hasTerminalEvent = hasRunFinished || hasRunError;
|
||||
const terminalEventMissing = !hasTerminalEvent;
|
||||
|
||||
for (const messageId of openMessageIds) {
|
||||
const endEvent = {
|
||||
type: EventType.TEXT_MESSAGE_END,
|
||||
messageId,
|
||||
} as BaseEvent;
|
||||
events.push(endEvent);
|
||||
appended.push(endEvent);
|
||||
}
|
||||
|
||||
for (const [toolCallId, info] of openToolCalls) {
|
||||
if (!info.hasEnd) {
|
||||
const endEvent = {
|
||||
type: EventType.TOOL_CALL_END,
|
||||
toolCallId,
|
||||
} as BaseEvent;
|
||||
events.push(endEvent);
|
||||
appended.push(endEvent);
|
||||
}
|
||||
|
||||
if (terminalEventMissing && !info.hasResult) {
|
||||
const resultEvent = {
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
toolCallId,
|
||||
messageId: `${toolCallId ?? randomUUID()}-result`,
|
||||
role: "tool",
|
||||
content: JSON.stringify(
|
||||
stopRequested
|
||||
? {
|
||||
status: "stopped",
|
||||
reason: "stop_requested",
|
||||
message: resolvedStopMessage,
|
||||
}
|
||||
: {
|
||||
status: "error",
|
||||
reason: "missing_terminal_event",
|
||||
message: resolvedAbruptMessage,
|
||||
},
|
||||
),
|
||||
} as BaseEvent;
|
||||
events.push(resultEvent);
|
||||
appended.push(resultEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalEventMissing) {
|
||||
if (stopRequested) {
|
||||
const finishedEvent = {
|
||||
type: EventType.RUN_FINISHED,
|
||||
} as BaseEvent;
|
||||
events.push(finishedEvent);
|
||||
appended.push(finishedEvent);
|
||||
} else {
|
||||
const errorEvent: RunErrorEvent = {
|
||||
type: EventType.RUN_ERROR,
|
||||
message: resolvedAbruptMessage,
|
||||
code: "INCOMPLETE_STREAM",
|
||||
};
|
||||
events.push(errorEvent);
|
||||
appended.push(errorEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return appended;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./constants";
|
||||
export * from "./telemetry";
|
||||
export * from "./debug";
|
||||
export * from "./standard-schema";
|
||||
export * from "./attachments";
|
||||
|
||||
export { logger } from "./logger";
|
||||
export { finalizeRunEvents } from "./finalize-events";
|
||||
|
||||
export {
|
||||
TranscriptionErrorCode,
|
||||
TranscriptionErrors,
|
||||
type TranscriptionErrorResponse,
|
||||
} from "./transcription-errors";
|
||||
|
||||
import * as packageJson from "../package.json";
|
||||
export const COPILOTKIT_VERSION = packageJson.version;
|
||||
|
||||
// Re-export only types from license-verifier (types are erased at compile time,
|
||||
// so they don't pull in the Node-only `crypto` dependency into client bundles).
|
||||
// Server-side packages (e.g. @copilotkit/runtime) should import runtime functions
|
||||
// like createLicenseChecker and getLicenseWarningHeader directly from
|
||||
// @copilotkit/license-verifier.
|
||||
export type {
|
||||
LicenseChecker,
|
||||
LicenseStatus,
|
||||
LicensePayload,
|
||||
LicenseFeatures,
|
||||
LicenseTier,
|
||||
LicenseOwner,
|
||||
} from "@copilotkit/license-verifier";
|
||||
|
||||
import type { LicensePayload } from "@copilotkit/license-verifier";
|
||||
import type { RuntimeLicenseStatus } from "./utils/types";
|
||||
|
||||
// LicenseContextValue was dropped from license-verifier's public API in
|
||||
// 0.3.0, so it is defined here. The context shape is owned by this package
|
||||
// anyway via createLicenseContextValue below.
|
||||
|
||||
/**
|
||||
* License context value exposed to child components.
|
||||
* Frontend providers create their own context using this shape.
|
||||
*/
|
||||
export interface LicenseContextValue {
|
||||
/** Server-reported license status from the runtime's /info endpoint. Null until known. */
|
||||
status: RuntimeLicenseStatus | null;
|
||||
/** The license payload if available. Always null on the client; the payload stays server-side. */
|
||||
license: LicensePayload | null;
|
||||
/** Whether a specific feature is licensed. Returns true if no licensing is active (no token). */
|
||||
checkFeature: (feature: string) => boolean;
|
||||
/** Get a numeric feature limit. Returns null if not applicable. */
|
||||
getLimit: (feature: string) => number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-safe license context factory, driven by the license status the
|
||||
* runtime reports via /info.
|
||||
*
|
||||
* Features are enabled unless the runtime definitively reports the license
|
||||
* as "expired" or "invalid". A null/"none"/"unknown" status fails open
|
||||
* (unlicensed = unrestricted, with branding), and "expiring" keeps features
|
||||
* on while the provider surfaces a warning banner. Per-feature data is not
|
||||
* in /info yet, so checkFeature is uniform across features and getLimit has
|
||||
* no limits to report. This is inlined here to avoid importing the full
|
||||
* license-verifier bundle (which depends on Node's `crypto`) into browser
|
||||
* bundles.
|
||||
*/
|
||||
export function createLicenseContextValue(
|
||||
status: RuntimeLicenseStatus | null | undefined,
|
||||
): LicenseContextValue {
|
||||
const resolvedStatus = status ?? null;
|
||||
const featuresEnabled =
|
||||
resolvedStatus !== "expired" && resolvedStatus !== "invalid";
|
||||
return {
|
||||
status: resolvedStatus,
|
||||
license: null,
|
||||
checkFeature: () => featuresEnabled,
|
||||
getLimit: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
A2UI_DEFAULT_GENERATION_GUIDELINES,
|
||||
A2UI_DEFAULT_DESIGN_GUIDELINES,
|
||||
} from "./a2ui-prompts";
|
||||
|
||||
export type { DebugEventEnvelope } from "./debug-event-envelope";
|
||||
@@ -0,0 +1 @@
|
||||
export const logger = console;
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
StandardSchemaV1,
|
||||
StandardJSONSchemaV1,
|
||||
} from "@standard-schema/spec";
|
||||
|
||||
export type { StandardSchemaV1, StandardJSONSchemaV1 };
|
||||
|
||||
/**
|
||||
* Extract the Output type from a StandardSchemaV1 schema.
|
||||
* Replaces `z.infer<S>` for generic schema inference.
|
||||
*/
|
||||
export type InferSchemaOutput<S> =
|
||||
S extends StandardSchemaV1<any, infer O> ? O : never;
|
||||
|
||||
export interface SchemaToJsonSchemaOptions {
|
||||
/**
|
||||
* Injected `zodToJsonSchema` function so that `shared` does not depend on
|
||||
* `zod-to-json-schema`. Required when the schema is a Zod v3 schema that
|
||||
* does not implement Standard JSON Schema V1.
|
||||
*/
|
||||
zodToJsonSchema?: (
|
||||
schema: unknown,
|
||||
options?: { $refStrategy?: string },
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a schema implements the Standard JSON Schema V1 protocol.
|
||||
*/
|
||||
function hasStandardJsonSchema(
|
||||
schema: StandardSchemaV1,
|
||||
): schema is StandardSchemaV1 & StandardJSONSchemaV1 {
|
||||
const props = schema["~standard"];
|
||||
return (
|
||||
props != null &&
|
||||
typeof props === "object" &&
|
||||
"jsonSchema" in props &&
|
||||
props.jsonSchema != null &&
|
||||
typeof props.jsonSchema === "object" &&
|
||||
"input" in props.jsonSchema &&
|
||||
typeof props.jsonSchema.input === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any StandardSchemaV1-compatible schema to a JSON Schema object.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. If the schema implements Standard JSON Schema V1 (`~standard.jsonSchema`),
|
||||
* call `schema['~standard'].jsonSchema.input({ target: 'draft-07' })`.
|
||||
* 2. If the schema exposes a `toJSONSchema()` method (Zod v4), call it directly.
|
||||
* 3. If the schema is a Zod v3 schema (`~standard.vendor === 'zod'`), use the
|
||||
* injected `zodToJsonSchema()` function.
|
||||
* 4. Otherwise throw a descriptive error.
|
||||
*/
|
||||
export function schemaToJsonSchema(
|
||||
schema: StandardSchemaV1,
|
||||
options?: SchemaToJsonSchemaOptions,
|
||||
): Record<string, unknown> {
|
||||
// 1. Standard JSON Schema V1
|
||||
if (hasStandardJsonSchema(schema)) {
|
||||
return schema["~standard"].jsonSchema.input({ target: "draft-07" });
|
||||
}
|
||||
|
||||
// 2. Zod v4 native — exposes toJSONSchema() on the schema itself
|
||||
if (typeof (schema as any).toJSONSchema === "function") {
|
||||
return (schema as any).toJSONSchema() as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 3. Zod v3 fallback
|
||||
const vendor = schema["~standard"].vendor;
|
||||
if (vendor === "zod" && options?.zodToJsonSchema) {
|
||||
return options.zodToJsonSchema(schema, { $refStrategy: "none" });
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cannot convert schema to JSON Schema. The schema (vendor: "${vendor}") does not implement Standard JSON Schema V1 ` +
|
||||
`and no zodToJsonSchema fallback is available. ` +
|
||||
`Use a library that supports Standard JSON Schema (e.g., Zod 3.24+, Valibot v1+, ArkType v2+) ` +
|
||||
`or pass a zodToJsonSchema function in options.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type AnalyticsEvents = {
|
||||
"oss.runtime.instance_created": RuntimeInstanceCreatedInfo;
|
||||
"oss.runtime.copilot_request_created": {
|
||||
"cloud.guardrails.enabled": boolean;
|
||||
requestType: string;
|
||||
"cloud.api_key_provided": boolean;
|
||||
"cloud.public_api_key"?: string;
|
||||
"cloud.base_url"?: string;
|
||||
};
|
||||
"oss.runtime.agent_execution_stream_started": { hashedLgcKey?: string };
|
||||
"oss.runtime.agent_execution_stream_ended": AgentExecutionResponseInfo;
|
||||
"oss.runtime.agent_execution_stream_errored": {
|
||||
hashedLgcKey?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface RuntimeInstanceCreatedInfo {
|
||||
actionsAmount: number;
|
||||
endpointTypes: string[];
|
||||
hashedLgcKey?: string;
|
||||
endpointsAmount: number;
|
||||
agentsAmount?: number | null;
|
||||
"cloud.api_key_provided": boolean;
|
||||
"cloud.public_api_key"?: string;
|
||||
"cloud.base_url"?: string;
|
||||
}
|
||||
|
||||
export interface AgentExecutionResponseInfo {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
langGraphHost?: string;
|
||||
langGraphVersion?: string;
|
||||
hashedLgcKey?: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./telemetry-client";
|
||||
export {
|
||||
lambdaClient,
|
||||
parseTelemetryIdFromLicense,
|
||||
parseAndWarnTelemetryId,
|
||||
} from "./lambda-client";
|
||||
export type { LambdaSendOptions } from "./lambda-client";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { send } from "./lambda-client";
|
||||
|
||||
describe("lambda-client send()", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 202 }));
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function bodyOf(callIdx = 0): {
|
||||
properties: Record<string, unknown>;
|
||||
global_properties: Record<string, unknown>;
|
||||
} {
|
||||
const init = fetchMock.mock.calls[callIdx][1] as RequestInit;
|
||||
return JSON.parse(init.body as string);
|
||||
}
|
||||
|
||||
function headersOf(callIdx = 0): Record<string, string> {
|
||||
const init = fetchMock.mock.calls[callIdx][1] as RequestInit;
|
||||
return init.headers as Record<string, string>;
|
||||
}
|
||||
|
||||
test("strips cloud.public_api_key from properties before sending", async () => {
|
||||
await send({
|
||||
event: "oss.runtime.copilot_request_created",
|
||||
properties: {
|
||||
requestType: "run",
|
||||
"cloud.api_key_provided": true,
|
||||
"cloud.public_api_key": "ck_live_abc.secret-blob",
|
||||
},
|
||||
});
|
||||
|
||||
const body = bodyOf();
|
||||
expect(body.properties).not.toHaveProperty("cloud.public_api_key");
|
||||
// Boolean indicator stays — it's not the key itself.
|
||||
expect(body.properties).toMatchObject({
|
||||
requestType: "run",
|
||||
"cloud.api_key_provided": true,
|
||||
});
|
||||
});
|
||||
|
||||
test("strips cloud.publicApiKey from globalProperties (v1 camelCase variant)", async () => {
|
||||
await send({
|
||||
event: "oss.runtime.instance_created",
|
||||
globalProperties: {
|
||||
"cloud.publicApiKey": "ck_live_abc.secret-blob",
|
||||
"cloud.baseUrl": "https://api.cloud.copilotkit.ai",
|
||||
sampleRate: 0.05,
|
||||
},
|
||||
});
|
||||
|
||||
const body = bodyOf();
|
||||
expect(body.global_properties).not.toHaveProperty("cloud.publicApiKey");
|
||||
// baseUrl is unrelated to attribution and rides through.
|
||||
expect(body.global_properties).toMatchObject({
|
||||
"cloud.baseUrl": "https://api.cloud.copilotkit.ai",
|
||||
sampleRate: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits X-CopilotKit-Telemetry-Id when license JWT carries telemetry_id", async () => {
|
||||
const payload = Buffer.from('{"telemetry_id":"abc-123"}').toString(
|
||||
"base64url",
|
||||
);
|
||||
const token = `header.${payload}.sig`;
|
||||
|
||||
await send({
|
||||
event: "oss.runtime.instance_created",
|
||||
licenseToken: token,
|
||||
});
|
||||
|
||||
expect(headersOf()["X-CopilotKit-Telemetry-Id"]).toBe("abc-123");
|
||||
});
|
||||
|
||||
test("falls through to anonymous when license JWT has no telemetry_id", async () => {
|
||||
const payload = Buffer.from('{"license_id":"foo"}').toString("base64url");
|
||||
const token = `header.${payload}.sig`;
|
||||
|
||||
await send({
|
||||
event: "oss.runtime.instance_created",
|
||||
licenseToken: token,
|
||||
});
|
||||
|
||||
expect(headersOf()["X-CopilotKit-Telemetry-Id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("falls through to anonymous when license token isn't a JWT shape", async () => {
|
||||
await send({
|
||||
event: "oss.runtime.instance_created",
|
||||
licenseToken: "not-a-jwt",
|
||||
});
|
||||
|
||||
expect(headersOf()["X-CopilotKit-Telemetry-Id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("swallows fetch errors silently", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
await expect(
|
||||
send({ event: "oss.runtime.instance_created" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// Telemetry sink client.
|
||||
//
|
||||
// Posts events to a CopilotKit-controlled telemetry-sink endpoint, which
|
||||
// fans out to Scarf, Reo, and any future destinations. Replaces the direct
|
||||
// per-vendor calls (scarf-client.ts) so that vendor changes don't require
|
||||
// SDK releases and so that downstream services we don't want exposed to
|
||||
// OSS readers (e.g. the email-enrichment service backing Reo) stay
|
||||
// private.
|
||||
//
|
||||
// Two attribution modes:
|
||||
// - Identified: a CopilotKit license token is configured. The token is
|
||||
// a JWT (header.payload.sig) whose payload carries `telemetry_id`.
|
||||
// The SDK base64url-decodes the payload — without verifying the
|
||||
// Ed25519 signature, which is the license-verifier's job — and
|
||||
// emits the id via `X-CopilotKit-Telemetry-Id`. The Lambda uses it
|
||||
// to enrich events with the customer's email.
|
||||
// - Anonymous: no license token, or a malformed/non-JWT one. No
|
||||
// telemetry-id header; events still flow, attribution is best-effort
|
||||
// from request-level signals (IP, UA).
|
||||
//
|
||||
// Note: CopilotCloud customer API keys (`ck_<env>_<id>.<secret>`) are
|
||||
// unrelated to telemetry attribution. They flow into Segment / PostHog
|
||||
// via the v1 shared TelemetryClient and never reach this code path.
|
||||
//
|
||||
// Best-effort: every error is swallowed. Telemetry must not break the
|
||||
// host application.
|
||||
|
||||
const TELEMETRY_SINK_URL =
|
||||
(typeof process !== "undefined" && process.env?.COPILOTKIT_TELEMETRY_URL) ||
|
||||
"https://telemetry.copilotkit.ai/ingest";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
|
||||
export interface LambdaSendOptions {
|
||||
event: string;
|
||||
properties?: Record<string, unknown>;
|
||||
globalProperties?: Record<string, unknown>;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
// The CopilotKit license token (Ed25519-signed JWT), when one is
|
||||
// configured on the runtime. The sender base64url-decodes the payload
|
||||
// segment to extract `telemetry_id`; missing or malformed tokens
|
||||
// produce an anonymous send.
|
||||
licenseToken?: string;
|
||||
}
|
||||
|
||||
// These fields aren't used by the telemetry service, so we strip them
|
||||
// at the wire boundary rather than rely on every caller to omit them.
|
||||
// Both the snake_case and camelCase variants are listed because callers
|
||||
// upstream use different conventions.
|
||||
const STRIPPED_KEYS = new Set(["cloud.public_api_key", "cloud.publicApiKey"]);
|
||||
|
||||
function stripCloudKeys(
|
||||
obj: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!obj) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (!STRIPPED_KEYS.has(k)) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pull telemetry_id out of a CopilotKit license token without verifying
|
||||
// the signature. The token shape is a standard JWT
|
||||
// (`<header>.<payload>.<sig>`) with base64url-encoded segments; the
|
||||
// payload is JSON with a `telemetry_id` string field.
|
||||
//
|
||||
// Verification (Ed25519, key rotation, expiry) is the license-verifier
|
||||
// package's job. For telemetry attribution we only need the claimed id —
|
||||
// the trust model is claim-only on the Lambda side anyway.
|
||||
//
|
||||
// Exported so TelemetryClient setters can detect unparseable tokens at
|
||||
// configuration time and surface a single warning, instead of silently
|
||||
// emitting anonymous events on every capture.
|
||||
export function parseTelemetryIdFromLicense(token?: string): string | null {
|
||||
if (!token) return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = (4 - (b64.length % 4)) % 4;
|
||||
b64 += "=".repeat(padding);
|
||||
const json =
|
||||
typeof atob === "function"
|
||||
? atob(b64)
|
||||
: Buffer.from(b64, "base64").toString("utf8");
|
||||
const decoded = JSON.parse(json) as { telemetry_id?: unknown };
|
||||
return typeof decoded.telemetry_id === "string"
|
||||
? decoded.telemetry_id
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the telemetry_id from a license token AND emit the rollout smoke
|
||||
// signal if the parse returned null. Returning the parsed id lets callers
|
||||
// cache it in one step (avoiding a second parseTelemetryIdFromLicense
|
||||
// pass) while keeping the warn text in lockstep between v1 (shared) and
|
||||
// v2 (runtime) TelemetryClient.setLicenseToken.
|
||||
export function parseAndWarnTelemetryId(licenseToken: string): string | null {
|
||||
const telemetryId = parseTelemetryIdFromLicense(licenseToken);
|
||||
if (!telemetryId) {
|
||||
console.warn(
|
||||
"[CopilotKit] License token did not yield a telemetry_id; telemetry events will be sent anonymously.",
|
||||
);
|
||||
}
|
||||
return telemetryId;
|
||||
}
|
||||
|
||||
export async function send(opts: LambdaSendOptions): Promise<void> {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
event: opts.event,
|
||||
properties: stripCloudKeys(opts.properties),
|
||||
global_properties: stripCloudKeys(opts.globalProperties),
|
||||
package: {
|
||||
name: opts.packageName,
|
||||
version: opts.packageVersion,
|
||||
},
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
const telemetryId = parseTelemetryIdFromLicense(opts.licenseToken);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": opts.packageName
|
||||
? `CopilotKit-Runtime/${opts.packageVersion ?? "unknown"} (${opts.packageName})`
|
||||
: "CopilotKit-Runtime",
|
||||
};
|
||||
if (telemetryId) {
|
||||
headers["X-CopilotKit-Telemetry-Id"] = telemetryId;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
await fetch(TELEMETRY_SINK_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch {
|
||||
// Silent failure — telemetry must not break the application.
|
||||
}
|
||||
}
|
||||
|
||||
export const lambdaClient = { send };
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as packageJson from "../../package.json";
|
||||
|
||||
const SCARF_BASE_URL = `https://copilotkit.gateway.scarf.sh/${packageJson.version}`;
|
||||
|
||||
class ScarfClient {
|
||||
constructor() {}
|
||||
|
||||
async logEvent(properties: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
Object.entries(properties).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${SCARF_BASE_URL}?${queryParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - telemetry should not break the application
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ScarfClient();
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { MockInstance } from "vitest";
|
||||
import { lambdaClient } from "./lambda-client";
|
||||
import { TelemetryClient, isTelemetryDisabled } from "./telemetry-client";
|
||||
|
||||
// Module mock so constructing TelemetryClient doesn't spin up segment's
|
||||
// internal flush queue. Class-based (not vi.fn) so `vi.restoreAllMocks()`
|
||||
// between tests doesn't wipe the `track` binding on subsequent `new
|
||||
// Analytics(...)` calls.
|
||||
const { segmentTrackMock } = vi.hoisted(() => ({
|
||||
segmentTrackMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@segment/analytics-node", () => ({
|
||||
Analytics: class {
|
||||
track = segmentTrackMock;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("v1 TelemetryClient", () => {
|
||||
let lambdaSpy: MockInstance<typeof lambdaClient.send>;
|
||||
|
||||
beforeEach(() => {
|
||||
lambdaSpy = vi.spyOn(lambdaClient, "send").mockResolvedValue(undefined);
|
||||
segmentTrackMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeClient(
|
||||
overrides: Partial<ConstructorParameters<typeof TelemetryClient>[0]> = {},
|
||||
): TelemetryClient {
|
||||
return new TelemetryClient({
|
||||
packageName: "@copilotkit/shared",
|
||||
packageVersion: "1.0.0",
|
||||
sampleRate: 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function jwtWith(payload: Record<string, unknown>): string {
|
||||
const b64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `header.${b64}.sig`;
|
||||
}
|
||||
|
||||
const baseInstanceEvent = {
|
||||
actionsAmount: 0,
|
||||
endpointsAmount: 0,
|
||||
endpointTypes: [],
|
||||
"cloud.api_key_provided": false,
|
||||
} as const;
|
||||
|
||||
test("capture sends to both sinks when sampled in (anonymous, one decision gates both)", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const client = makeClient({ sampleRate: 0.05 });
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).toHaveBeenCalledTimes(1);
|
||||
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
|
||||
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
||||
event: "oss.runtime.instance_created",
|
||||
});
|
||||
});
|
||||
|
||||
test("capture skips both sinks when anonymous and sampled out", async () => {
|
||||
// Math.random=0.99 vs sampleRate=0.05 — anonymous caller is gated out;
|
||||
// neither sink should fire under the new one-decision-both-sinks model.
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||
const client = makeClient({ sampleRate: 0.05 });
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).not.toHaveBeenCalled();
|
||||
expect(segmentTrackMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("identified callers bypass the sample gate (lambda + segment fire even when Math.random would fail)", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||
const client = makeClient({ sampleRate: 0.05 });
|
||||
client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).toHaveBeenCalledTimes(1);
|
||||
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("identified callers send to both sinks on every capture", async () => {
|
||||
const client = makeClient({ sampleRate: 0.05 });
|
||||
client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).toHaveBeenCalledTimes(2);
|
||||
expect(segmentTrackMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("capture short-circuits both sinks when telemetryDisabled is true", async () => {
|
||||
const client = makeClient({ telemetryDisabled: true });
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).not.toHaveBeenCalled();
|
||||
expect(segmentTrackMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("setLicenseToken forwards the token in subsequent capture", async () => {
|
||||
const token = jwtWith({ telemetry_id: "abc-123" });
|
||||
const client = makeClient();
|
||||
client.setLicenseToken(token);
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).toHaveBeenCalledTimes(1);
|
||||
expect(lambdaSpy.mock.calls[0][0].licenseToken).toBe(token);
|
||||
});
|
||||
|
||||
test("capture sends licenseToken=undefined when setLicenseToken was never called", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const client = makeClient();
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy.mock.calls[0][0].licenseToken).toBeUndefined();
|
||||
});
|
||||
|
||||
test("setLicenseToken warns once when the token has no telemetry_id", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const client = makeClient();
|
||||
|
||||
client.setLicenseToken(jwtWith({ license_id: "x" }));
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toMatch(/telemetry_id/);
|
||||
});
|
||||
|
||||
test("setLicenseToken does not warn when the token carries telemetry_id", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const client = makeClient();
|
||||
|
||||
client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("identified events carry sampleWeight=1 (anonymous events carry 1/sampleRate)", async () => {
|
||||
// Anonymous: sampleWeight should be 1 / sampleRate so downstream
|
||||
// weight-based extrapolation reconstructs true volume.
|
||||
// Identified: bypassing the gate means each event represents itself,
|
||||
// so sampleWeight must be 1 — not the population's 1/sampleRate.
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const anonClient = makeClient({ sampleRate: 0.05 });
|
||||
await anonClient.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
|
||||
sampleRate: 0.05,
|
||||
sampleWeight: 20,
|
||||
});
|
||||
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
||||
properties: expect.objectContaining({
|
||||
sampleRate: 0.05,
|
||||
sampleWeight: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
lambdaSpy.mockClear();
|
||||
segmentTrackMock.mockReset();
|
||||
|
||||
const idClient = makeClient({ sampleRate: 0.05 });
|
||||
idClient.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
|
||||
await idClient.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
|
||||
sampleRate: 1,
|
||||
sampleWeight: 1,
|
||||
});
|
||||
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
||||
properties: expect.objectContaining({ sampleRate: 1, sampleWeight: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed license token stays anonymous and remains sample-gated", async () => {
|
||||
// parseTelemetryIdFromLicense returns null for any of: empty token,
|
||||
// wrong-shape (not three dot-separated segments), base64/JSON parse
|
||||
// failure. A misconfigured customer must not flip to identified-bypass.
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const client = makeClient({ sampleRate: 0.05 });
|
||||
|
||||
client.setLicenseToken("not-a-jwt");
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).not.toHaveBeenCalled();
|
||||
expect(segmentTrackMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("setLicenseToken cache is overwritable (good token replaced by bad → back to anonymous gate)", async () => {
|
||||
// Pins the cache as last-write-wins so a refactor to first-write-wins
|
||||
// (e.g. `this.telemetryId ??= parseAndWarnTelemetryId(...)`) doesn't
|
||||
// leak identified-bypass status across license replacements.
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const client = makeClient({ sampleRate: 0.05 });
|
||||
|
||||
client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
|
||||
client.setLicenseToken(jwtWith({ license_id: "no-telemetry-id" }));
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
expect(lambdaSpy).not.toHaveBeenCalled();
|
||||
expect(segmentTrackMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("setCloudConfiguration writes cloud keys into globalProperties for both sinks", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const client = makeClient({ sampleRate: 1 });
|
||||
client.setCloudConfiguration({
|
||||
publicApiKey: "ck_live_test.secret",
|
||||
baseUrl: "https://api.cloud.copilotkit.ai",
|
||||
});
|
||||
|
||||
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
||||
|
||||
// v1 still ships cloud.publicApiKey through globalProperties — the
|
||||
// lambda-client.send sanitization strips it at the wire (covered in
|
||||
// lambda-client.test.ts), and Segment retains it intentionally for
|
||||
// existing CopilotCloud user analytics.
|
||||
expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
|
||||
"cloud.publicApiKey": "ck_live_test.secret",
|
||||
"cloud.baseUrl": "https://api.cloud.copilotkit.ai",
|
||||
});
|
||||
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
||||
properties: expect.objectContaining({
|
||||
"cloud.publicApiKey": "ck_live_test.secret",
|
||||
"cloud.baseUrl": "https://api.cloud.copilotkit.ai",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("constructor rejects sampleRate outside [0, 1]", () => {
|
||||
expect(() => makeClient({ sampleRate: 1.5 })).toThrow(
|
||||
"Sample rate must be between 0 and 1",
|
||||
);
|
||||
expect(() => makeClient({ sampleRate: -0.1 })).toThrow(
|
||||
"Sample rate must be between 0 and 1",
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor rejects NaN sampleRate from a malformed env override", () => {
|
||||
// parseFloat('nonsense') = NaN; without the explicit guard, NaN slips
|
||||
// past the range check (all NaN comparisons are false) and produces a
|
||||
// silent always-drop. Guard the validator with Number.isNaN.
|
||||
const original = process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
||||
process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = "not-a-number";
|
||||
try {
|
||||
expect(() => makeClient()).toThrow("Sample rate must be between 0 and 1");
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
||||
} else {
|
||||
process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTelemetryDisabled", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test.each([
|
||||
["COPILOTKIT_TELEMETRY_DISABLED", "true"],
|
||||
["COPILOTKIT_TELEMETRY_DISABLED", "1"],
|
||||
["DO_NOT_TRACK", "true"],
|
||||
["DO_NOT_TRACK", "1"],
|
||||
])("returns true when %s=%s", (key, val) => {
|
||||
process.env[key] = val;
|
||||
expect(isTelemetryDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when no opt-out env var is set", () => {
|
||||
delete process.env.COPILOTKIT_TELEMETRY_DISABLED;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
expect(isTelemetryDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Analytics } from "@segment/analytics-node";
|
||||
import type { AnalyticsEvents } from "./events";
|
||||
import { flattenObject } from "./utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lambdaClient, parseAndWarnTelemetryId } from "./lambda-client";
|
||||
|
||||
/**
|
||||
* Checks if telemetry is disabled via environment variables.
|
||||
* Users can opt out by setting:
|
||||
* - COPILOTKIT_TELEMETRY_DISABLED=true or COPILOTKIT_TELEMETRY_DISABLED=1
|
||||
* - DO_NOT_TRACK=true or DO_NOT_TRACK=1
|
||||
*/
|
||||
export function isTelemetryDisabled(): boolean {
|
||||
return (
|
||||
(process.env as Record<string, string | undefined>)
|
||||
.COPILOTKIT_TELEMETRY_DISABLED === "true" ||
|
||||
(process.env as Record<string, string | undefined>)
|
||||
.COPILOTKIT_TELEMETRY_DISABLED === "1" ||
|
||||
(process.env as Record<string, string | undefined>).DO_NOT_TRACK ===
|
||||
"true" ||
|
||||
(process.env as Record<string, string | undefined>).DO_NOT_TRACK === "1"
|
||||
);
|
||||
}
|
||||
|
||||
export class TelemetryClient {
|
||||
segment: Analytics | undefined;
|
||||
globalProperties: Record<string, any> = {};
|
||||
cloudConfiguration: { publicApiKey: string; baseUrl: string } | null = null;
|
||||
// EIP / Intelligence license token (Ed25519-signed JWT). The lambda
|
||||
// client decodes its payload to extract telemetry_id. Customer API
|
||||
// keys are NOT used here — they flow only into Segment.
|
||||
private licenseToken: string | null = null;
|
||||
// Parsed telemetry_id from the license-token JWT payload. Cached at
|
||||
// setLicenseToken time so `capture()` can branch on identified vs
|
||||
// anonymous without re-parsing per event. Null when the token is
|
||||
// absent or yielded no telemetry_id.
|
||||
private telemetryId: string | null = null;
|
||||
packageName: string;
|
||||
packageVersion: string;
|
||||
private telemetryDisabled: boolean = false;
|
||||
// Client-side sampling rate for anonymous events. Identified events
|
||||
// (those whose license token yielded a telemetry_id) bypass the gate
|
||||
// entirely. Applied uniformly to both the lambda sink and Segment —
|
||||
// one dice roll per capture, both sinks see the same decision.
|
||||
private sampleRate: number = 0.05;
|
||||
private anonymousId = `anon_${uuidv4()}`;
|
||||
|
||||
constructor({
|
||||
packageName,
|
||||
packageVersion,
|
||||
telemetryDisabled,
|
||||
telemetryBaseUrl,
|
||||
sampleRate,
|
||||
}: {
|
||||
packageName: string;
|
||||
packageVersion: string;
|
||||
telemetryDisabled?: boolean;
|
||||
telemetryBaseUrl?: string;
|
||||
sampleRate?: number;
|
||||
}) {
|
||||
this.packageName = packageName;
|
||||
this.packageVersion = packageVersion;
|
||||
this.telemetryDisabled = telemetryDisabled || isTelemetryDisabled();
|
||||
|
||||
if (this.telemetryDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSampleRate(sampleRate);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const writeKey =
|
||||
process.env.COPILOTKIT_SEGMENT_WRITE_KEY ||
|
||||
"n7XAZtQCGS2v1vvBy3LgBCv2h3Y8whja";
|
||||
|
||||
this.segment = new Analytics({
|
||||
writeKey,
|
||||
});
|
||||
|
||||
this.setGlobalProperties({
|
||||
"copilotkit.package.name": packageName,
|
||||
"copilotkit.package.version": packageVersion,
|
||||
});
|
||||
}
|
||||
|
||||
private shouldSendEvent() {
|
||||
const randomNumber = Math.random();
|
||||
return randomNumber < this.sampleRate;
|
||||
}
|
||||
|
||||
async capture<K extends keyof AnalyticsEvents>(
|
||||
event: K,
|
||||
properties: AnalyticsEvents[K],
|
||||
) {
|
||||
if (this.telemetryDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Anonymous callers (no telemetry_id) are gated by sampleRate.
|
||||
// Identified callers (license token with telemetry_id) always send —
|
||||
// the volume is bounded by paying-customer count and full fidelity
|
||||
// per identified customer is worth the marginal cost.
|
||||
if (!this.telemetryId && !this.shouldSendEvent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Identified events ship at 100% effective rate, anonymous events at
|
||||
// sampleRate. Compute per-event so downstream weight-based extrapolation
|
||||
// (sampleWeight = 1 / effectiveRate) is correct for both populations;
|
||||
// a single global sampleWeight would overweight identified-customer
|
||||
// counts by 1/sampleRate.
|
||||
const effectiveSampleRate = this.telemetryId ? 1 : this.sampleRate;
|
||||
const samplingMeta = {
|
||||
sampleRate: effectiveSampleRate,
|
||||
sampleRateAdjustmentFactor: 1 - effectiveSampleRate,
|
||||
sampleWeight: 1 / effectiveSampleRate,
|
||||
};
|
||||
|
||||
const flattenedProperties = flattenObject(properties);
|
||||
const propertiesWithGlobal: Record<string, any> = {
|
||||
...this.globalProperties,
|
||||
...samplingMeta,
|
||||
...flattenedProperties,
|
||||
};
|
||||
const orderedPropertiesWithGlobal = Object.keys(propertiesWithGlobal)
|
||||
.sort()
|
||||
.reduce(
|
||||
(obj, key) => {
|
||||
obj[key] = propertiesWithGlobal[key];
|
||||
return obj;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
await lambdaClient.send({
|
||||
event,
|
||||
properties: flattenedProperties,
|
||||
globalProperties: { ...this.globalProperties, ...samplingMeta },
|
||||
packageName: this.packageName,
|
||||
packageVersion: this.packageVersion,
|
||||
licenseToken: this.licenseToken ?? undefined,
|
||||
});
|
||||
|
||||
if (this.segment) {
|
||||
this.segment.track({
|
||||
anonymousId: this.anonymousId,
|
||||
event,
|
||||
properties: { ...orderedPropertiesWithGlobal },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalProperties(properties: Record<string, any>) {
|
||||
const flattenedProperties = flattenObject(properties);
|
||||
this.globalProperties = {
|
||||
...this.globalProperties,
|
||||
...flattenedProperties,
|
||||
};
|
||||
}
|
||||
|
||||
setCloudConfiguration(properties: { publicApiKey: string; baseUrl: string }) {
|
||||
this.cloudConfiguration = properties;
|
||||
|
||||
this.setGlobalProperties({
|
||||
cloud: {
|
||||
publicApiKey: properties.publicApiKey,
|
||||
baseUrl: properties.baseUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The license token isn't added to globalProperties — we don't want
|
||||
// the JWT itself shipped on every event. Only its decoded telemetry_id
|
||||
// travels, in the X-CopilotKit-Telemetry-Id header set by lambda-client.
|
||||
setLicenseToken(licenseToken: string) {
|
||||
this.licenseToken = licenseToken;
|
||||
this.telemetryId = parseAndWarnTelemetryId(licenseToken);
|
||||
}
|
||||
|
||||
private setSampleRate(sampleRate: number | undefined) {
|
||||
let _sampleRate: number;
|
||||
|
||||
_sampleRate = sampleRate ?? 0.05;
|
||||
|
||||
// eslint-disable-next-line
|
||||
if (process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE) {
|
||||
// eslint-disable-next-line
|
||||
_sampleRate = parseFloat(process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
// Number.isNaN guards against parseFloat("nonsense") slipping past the
|
||||
// range check (all NaN comparisons are false), which would silently
|
||||
// drop every anonymous event with no signal — especially important
|
||||
// since the default is now 0.05, making env-var overrides more common.
|
||||
if (Number.isNaN(_sampleRate) || _sampleRate < 0 || _sampleRate > 1) {
|
||||
throw new Error("Sample rate must be between 0 and 1");
|
||||
}
|
||||
|
||||
this.sampleRate = _sampleRate;
|
||||
// Per-event sampling metadata (sampleRate/sampleRateAdjustmentFactor/
|
||||
// sampleWeight) is computed in capture() so identified events get
|
||||
// their own effectiveSampleRate=1 weight instead of the anonymous
|
||||
// population's 1/sampleRate.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
export function flattenObject(
|
||||
obj: Record<string, any>,
|
||||
parentKey = "",
|
||||
res: Record<string, any> = {},
|
||||
): Record<string, any> {
|
||||
for (let key in obj) {
|
||||
const propName = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
flattenObject(obj[key], propName, res);
|
||||
} else {
|
||||
res[propName] = obj[key];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function printSecurityNotice(advisory: {
|
||||
advisory: string | null;
|
||||
message: string;
|
||||
severity: "low" | "medium" | "high" | "none";
|
||||
}) {
|
||||
const severityColor =
|
||||
{
|
||||
low: chalk.blue,
|
||||
medium: chalk.yellow,
|
||||
high: chalk.red,
|
||||
}[advisory.severity.toLowerCase()] || chalk.white;
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
`━━━━━━━━━━━━━━━━━━ ${chalk.bold(`CopilotKit`)} ━━━━━━━━━━━━━━━━━━`,
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
`${chalk.bold(`Severity: ${severityColor(advisory.severity.toUpperCase())}`)}`,
|
||||
);
|
||||
console.log();
|
||||
console.log(`${chalk.bold(advisory.message)}`);
|
||||
console.log();
|
||||
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Error codes for transcription HTTP responses.
|
||||
* Uses snake_case to align with existing CopilotKitCoreErrorCode pattern.
|
||||
* These codes are returned by the runtime and parsed by the client.
|
||||
*/
|
||||
export enum TranscriptionErrorCode {
|
||||
/** Transcription service not configured in runtime */
|
||||
SERVICE_NOT_CONFIGURED = "service_not_configured",
|
||||
/** Audio format not supported */
|
||||
INVALID_AUDIO_FORMAT = "invalid_audio_format",
|
||||
/** Audio file is too long */
|
||||
AUDIO_TOO_LONG = "audio_too_long",
|
||||
/** Audio file is empty or too short */
|
||||
AUDIO_TOO_SHORT = "audio_too_short",
|
||||
/** Rate limited by transcription provider */
|
||||
RATE_LIMITED = "rate_limited",
|
||||
/** Authentication failed with transcription provider */
|
||||
AUTH_FAILED = "auth_failed",
|
||||
/** Transcription provider returned an error */
|
||||
PROVIDER_ERROR = "provider_error",
|
||||
/** Network error during transcription */
|
||||
NETWORK_ERROR = "network_error",
|
||||
/** Invalid request format */
|
||||
INVALID_REQUEST = "invalid_request",
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response format returned by the transcription endpoint.
|
||||
*/
|
||||
export interface TranscriptionErrorResponse {
|
||||
error: TranscriptionErrorCode;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper functions to create transcription error responses.
|
||||
* Used by the runtime to return consistent error responses.
|
||||
*/
|
||||
export const TranscriptionErrors = {
|
||||
serviceNotConfigured: (): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.SERVICE_NOT_CONFIGURED,
|
||||
message: "Transcription service is not configured",
|
||||
retryable: false,
|
||||
}),
|
||||
|
||||
invalidAudioFormat: (
|
||||
format: string,
|
||||
supported: string[],
|
||||
): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.INVALID_AUDIO_FORMAT,
|
||||
message: `Unsupported audio format: ${format}. Supported: ${supported.join(", ")}`,
|
||||
retryable: false,
|
||||
}),
|
||||
|
||||
invalidRequest: (details: string): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.INVALID_REQUEST,
|
||||
message: details,
|
||||
retryable: false,
|
||||
}),
|
||||
|
||||
rateLimited: (): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.RATE_LIMITED,
|
||||
message: "Rate limited. Please try again later.",
|
||||
retryable: true,
|
||||
}),
|
||||
|
||||
authFailed: (): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.AUTH_FAILED,
|
||||
message: "Authentication failed with transcription provider",
|
||||
retryable: false,
|
||||
}),
|
||||
|
||||
providerError: (message: string): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.PROVIDER_ERROR,
|
||||
message,
|
||||
retryable: true,
|
||||
}),
|
||||
|
||||
networkError: (
|
||||
message: string = "Network error during transcription",
|
||||
): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.NETWORK_ERROR,
|
||||
message,
|
||||
retryable: true,
|
||||
}),
|
||||
|
||||
audioTooLong: (): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.AUDIO_TOO_LONG,
|
||||
message: "Audio file is too long",
|
||||
retryable: false,
|
||||
}),
|
||||
|
||||
audioTooShort: (): TranscriptionErrorResponse => ({
|
||||
error: TranscriptionErrorCode.AUDIO_TOO_SHORT,
|
||||
message: "Audio is too short to transcribe",
|
||||
retryable: false,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
UserMessage,
|
||||
InputContent,
|
||||
TextInputPart,
|
||||
ImageInputPart,
|
||||
AudioInputPart,
|
||||
VideoInputPart,
|
||||
DocumentInputPart,
|
||||
InputContentSource,
|
||||
InputContentDataSource,
|
||||
InputContentUrlSource,
|
||||
} from "../message";
|
||||
|
||||
describe("shared message types", () => {
|
||||
it("UserMessage content accepts string", () => {
|
||||
const msg: UserMessage = { id: "1", role: "user", content: "hello" };
|
||||
expect(msg.content).toBe("hello");
|
||||
});
|
||||
|
||||
it("UserMessage content accepts InputContent[]", () => {
|
||||
const msg: UserMessage = {
|
||||
id: "1",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "data", value: "base64...", mimeType: "image/png" },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(Array.isArray(msg.content)).toBe(true);
|
||||
});
|
||||
|
||||
it("InputContent union covers all modalities", () => {
|
||||
const parts: InputContent[] = [
|
||||
{ type: "text", text: "hi" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "url", value: "https://example.com/img.png" },
|
||||
},
|
||||
{
|
||||
type: "audio",
|
||||
source: { type: "data", value: "base64...", mimeType: "audio/mp3" },
|
||||
},
|
||||
{
|
||||
type: "video",
|
||||
source: { type: "data", value: "base64...", mimeType: "video/mp4" },
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "base64...",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(parts).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
type TypeMap = {
|
||||
string: string;
|
||||
number: number;
|
||||
boolean: boolean;
|
||||
object: object;
|
||||
"string[]": string[];
|
||||
"number[]": number[];
|
||||
"boolean[]": boolean[];
|
||||
"object[]": object[];
|
||||
};
|
||||
|
||||
type AbstractParameter = {
|
||||
name: string;
|
||||
type?: keyof TypeMap;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
interface StringParameter extends AbstractParameter {
|
||||
type: "string";
|
||||
enum?: string[];
|
||||
}
|
||||
|
||||
interface ObjectParameter extends AbstractParameter {
|
||||
type: "object";
|
||||
attributes?: Parameter[];
|
||||
}
|
||||
|
||||
interface ObjectArrayParameter extends AbstractParameter {
|
||||
type: "object[]";
|
||||
attributes?: Parameter[];
|
||||
}
|
||||
|
||||
type SpecialParameters =
|
||||
| StringParameter
|
||||
| ObjectParameter
|
||||
| ObjectArrayParameter;
|
||||
interface BaseParameter extends AbstractParameter {
|
||||
type?: Exclude<AbstractParameter["type"], SpecialParameters["type"]>;
|
||||
}
|
||||
|
||||
export type Parameter = BaseParameter | SpecialParameters;
|
||||
|
||||
type OptionalParameterType<P extends AbstractParameter> =
|
||||
P["required"] extends false ? undefined : never;
|
||||
|
||||
type StringParameterType<P> = P extends StringParameter
|
||||
? P extends { enum?: Array<infer E> }
|
||||
? E
|
||||
: string
|
||||
: never;
|
||||
|
||||
type ObjectParameterType<P> = P extends ObjectParameter
|
||||
? P extends { attributes?: infer Attributes extends Parameter[] }
|
||||
? MappedParameterTypes<Attributes>
|
||||
: object
|
||||
: never;
|
||||
|
||||
type ObjectArrayParameterType<P> = P extends ObjectArrayParameter
|
||||
? P extends { attributes?: infer Attributes extends Parameter[] }
|
||||
? MappedParameterTypes<Attributes>[]
|
||||
: any[]
|
||||
: never;
|
||||
|
||||
type MappedTypeOrString<T> = T extends keyof TypeMap ? TypeMap[T] : string;
|
||||
type BaseParameterType<P extends AbstractParameter> = P extends {
|
||||
type: infer T;
|
||||
}
|
||||
? T extends BaseParameter["type"]
|
||||
? MappedTypeOrString<T>
|
||||
: never
|
||||
: string;
|
||||
|
||||
export type MappedParameterTypes<T extends Parameter[] | [] = []> = T extends []
|
||||
? Record<string, any>
|
||||
: {
|
||||
[P in T[number] as P["name"]]:
|
||||
| OptionalParameterType<P>
|
||||
| StringParameterType<P>
|
||||
| ObjectParameterType<P>
|
||||
| ObjectArrayParameterType<P>
|
||||
| BaseParameterType<P>;
|
||||
};
|
||||
|
||||
export type Action<T extends Parameter[] | [] = []> = {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: T;
|
||||
handler?: T extends []
|
||||
? () => any | Promise<any>
|
||||
: (args: MappedParameterTypes<T>) => any | Promise<any>;
|
||||
additionalConfig?: Record<string, any>;
|
||||
};
|
||||
|
||||
// This is the original "ceiling is being raised" version of MappedParameterTypes.
|
||||
//
|
||||
// ceiling is being raised. cursor's copilot helped us write "superhuman code"
|
||||
// for a critical feature. We can read this code, but VERY few engineers out
|
||||
// there could write it from scratch.
|
||||
// Took lots of convincing too. "come on, this must be possible, try harder".
|
||||
// and obviously- done in parts.
|
||||
//
|
||||
// - https://twitter.com/ataiiam/status/1765089261374914957
|
||||
// (Mar 5, 2024)
|
||||
//
|
||||
// export type MappedParameterTypes<T extends Parameter[]> = {
|
||||
// // Check if the parameter has an 'enum' defined
|
||||
// [P in T[number] as P["name"]]: P extends { enum: Array<infer E> }
|
||||
// ? E extends string // Ensure the enum values are strings
|
||||
// ? P["required"] extends false // Check if the parameter is optional
|
||||
// ? E | undefined // If so, include 'undefined' in the type
|
||||
// : E // Otherwise, use the enum type directly
|
||||
// : never // This case should not occur since 'enum' implies string values
|
||||
// : // Handle parameters defined as 'object' with specified attributes
|
||||
// P extends { type: "object"; attributes: infer Attributes }
|
||||
// ? Attributes extends Parameter[]
|
||||
// ? MappedParameterTypes<Attributes> // Recursively map the attributes of the object
|
||||
// : never // If 'attributes' is not an array of Parameters, this is invalid
|
||||
// : // Handle parameters defined as 'object[]' without specified attributes
|
||||
// P extends { type: "object[]"; attributes?: never }
|
||||
// ? any[] // Default to 'any[]' for arrays of objects without specific attributes
|
||||
// : // Handle parameters defined as 'object[]' with specified attributes
|
||||
// P extends { type: "object[]"; attributes: infer Attributes }
|
||||
// ? Attributes extends Parameter[]
|
||||
// ? MappedParameterTypes<Attributes>[] // Recursively map each object in the array
|
||||
// : any[] // Default to 'any[]' if attributes are not properly defined
|
||||
// : // Handle all other parameter types
|
||||
// P["required"] extends false
|
||||
// ? // Include 'undefined' for optional parameters
|
||||
// TypeMap[P["type"] extends keyof TypeMap ? P["type"] : "string"] | undefined
|
||||
// : // Use the direct mapping from 'TypeMap' for the parameter's type
|
||||
// TypeMap[P["type"] extends keyof TypeMap ? P["type"] : "string"];
|
||||
// };
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface CopilotCloudConfig {
|
||||
guardrails: {
|
||||
input: {
|
||||
restrictToTopic: {
|
||||
enabled: boolean;
|
||||
validTopics: string[];
|
||||
invalidTopics: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface CopilotErrorEvent {
|
||||
type:
|
||||
| "error"
|
||||
| "request"
|
||||
| "response"
|
||||
| "agent_state"
|
||||
| "action"
|
||||
| "message"
|
||||
| "performance";
|
||||
timestamp: number;
|
||||
context: CopilotRequestContext;
|
||||
error?: any; // Present when type is 'error'
|
||||
}
|
||||
|
||||
export interface CopilotRequestContext {
|
||||
// Basic identifiers
|
||||
threadId?: string;
|
||||
runId?: string;
|
||||
source: "runtime" | "ui" | "agent" | "network";
|
||||
|
||||
// Request details
|
||||
request?: {
|
||||
operation: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
// Response details
|
||||
response?: {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
endTime: number;
|
||||
latency: number;
|
||||
};
|
||||
|
||||
// Agent context
|
||||
agent?: {
|
||||
name?: string;
|
||||
nodeName?: string;
|
||||
state?: any;
|
||||
};
|
||||
|
||||
// Message flow context
|
||||
messages?: {
|
||||
input?: any[];
|
||||
output?: any[];
|
||||
messageCount?: number;
|
||||
};
|
||||
|
||||
// Technical context
|
||||
technical?: {
|
||||
userAgent?: string;
|
||||
host?: string;
|
||||
environment?: string;
|
||||
version?: string;
|
||||
stackTrace?: string;
|
||||
};
|
||||
|
||||
// Performance metrics
|
||||
performance?: {
|
||||
requestDuration?: number;
|
||||
streamingDuration?: number;
|
||||
actionExecutionTime?: number;
|
||||
memoryUsage?: number;
|
||||
};
|
||||
|
||||
// Extensible metadata
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type CopilotErrorHandler = (
|
||||
errorEvent: CopilotErrorEvent,
|
||||
) => void | Promise<void>;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./openai-assistant";
|
||||
export * from "./action";
|
||||
export * from "./copilot-cloud-config";
|
||||
export * from "./utility";
|
||||
export * from "./error";
|
||||
export * from "./message";
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as agui from "@ag-ui/core";
|
||||
|
||||
// Re-export AG-UI multimodal input types.
|
||||
// Note: AG-UI names the text variant TextInputContent; we export it as TextInputPart for consistency.
|
||||
export type {
|
||||
InputContent,
|
||||
InputContentSource,
|
||||
InputContentDataSource,
|
||||
InputContentUrlSource,
|
||||
TextInputContent as TextInputPart,
|
||||
ImageInputPart,
|
||||
AudioInputPart,
|
||||
VideoInputPart,
|
||||
DocumentInputPart,
|
||||
} from "@ag-ui/core";
|
||||
|
||||
/**
|
||||
* @deprecated Use `InputContentSource` from `@ag-ui/core` (re-exported from `@copilotkit/shared`) instead.
|
||||
* `ImageData` only described base64 image payloads. `InputContentSource` supports
|
||||
* data and URL sources for images, audio, video, and documents.
|
||||
* See https://docs.copilotkit.ai/migration-guides/migrate-attachments
|
||||
* @since 1.56.0
|
||||
*/
|
||||
export interface ImageData {
|
||||
format: string;
|
||||
bytes: string;
|
||||
}
|
||||
|
||||
// Pass through types
|
||||
export type Role = agui.Role;
|
||||
export type SystemMessage = agui.SystemMessage;
|
||||
export type DeveloperMessage = agui.DeveloperMessage;
|
||||
export type ToolCall = agui.ToolCall;
|
||||
export type ActivityMessage = agui.ActivityMessage;
|
||||
export type ReasoningMessage = agui.ReasoningMessage;
|
||||
|
||||
// Extended message types
|
||||
export type ToolResult = agui.ToolMessage & {
|
||||
toolName?: string;
|
||||
};
|
||||
|
||||
export type AIMessage = agui.AssistantMessage & {
|
||||
generativeUI?: (props?: any) => any;
|
||||
generativeUIPosition?: "before" | "after";
|
||||
agentName?: string;
|
||||
state?: any;
|
||||
/**
|
||||
* @deprecated Use multimodal `content` array with `InputContent` parts instead.
|
||||
* See https://docs.copilotkit.ai/migration-guides/migrate-attachments
|
||||
* @since 1.56.0
|
||||
*/
|
||||
image?: ImageData;
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
export type UserMessage = agui.UserMessage;
|
||||
|
||||
export type Message =
|
||||
| AIMessage
|
||||
| ToolResult
|
||||
| UserMessage
|
||||
| SystemMessage
|
||||
| DeveloperMessage
|
||||
| ActivityMessage
|
||||
| ReasoningMessage;
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface FunctionDefinition {
|
||||
/**
|
||||
* The name of the function to be called. Must be a-z, A-Z, 0-9, or contain
|
||||
* underscores and dashes, with a maximum length of 64.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The parameters the functions accepts, described as a JSON Schema object. See the
|
||||
* [guide](/docs/guides/gpt/function-calling) for examples, and the
|
||||
* [JSON Schema reference](https://json-schema.org/understanding-json-schema/) for
|
||||
* documentation about the format.
|
||||
*
|
||||
* To describe a function that accepts no parameters, provide the value
|
||||
* `{"type": "object", "properties": {}}`.
|
||||
*/
|
||||
parameters: Record<string, unknown>;
|
||||
/**
|
||||
* A description of what the function does, used by the model to choose when and
|
||||
* how to call the function.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
type: "function";
|
||||
function: FunctionDefinition;
|
||||
}
|
||||
|
||||
export interface FunctionCallHandlerArguments {
|
||||
messages: any[];
|
||||
name: string;
|
||||
args: any;
|
||||
}
|
||||
|
||||
export type FunctionCallHandler = (
|
||||
args: FunctionCallHandlerArguments,
|
||||
) => Promise<any>;
|
||||
|
||||
export type CoAgentStateRenderHandlerArguments = {
|
||||
name: string;
|
||||
nodeName: string;
|
||||
state: any;
|
||||
};
|
||||
|
||||
export type CoAgentStateRenderHandler = (
|
||||
args: CoAgentStateRenderHandlerArguments,
|
||||
) => Promise<any>;
|
||||
|
||||
export type AssistantMessage = {
|
||||
id: string;
|
||||
role: "assistant";
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: {
|
||||
value: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type JSONValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| { [x: string]: JSONValue }
|
||||
| Array<JSONValue>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
export type RequiredBy<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { partialJSONParse, safeParseToolArgs } from "./utils";
|
||||
|
||||
describe("partialJSONParse", () => {
|
||||
test("should parse a valid object", () => {
|
||||
expect(partialJSONParse('{"key": "value"}')).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
test("should return {} for a JSON string value", () => {
|
||||
expect(partialJSONParse('"hello"')).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON number", () => {
|
||||
expect(partialJSONParse("42")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON boolean", () => {
|
||||
expect(partialJSONParse("true")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON null", () => {
|
||||
expect(partialJSONParse("null")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON array", () => {
|
||||
expect(partialJSONParse("[1, 2, 3]")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for unparseable input", () => {
|
||||
expect(partialJSONParse("not-json")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for an empty string", () => {
|
||||
expect(partialJSONParse("")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeParseToolArgs", () => {
|
||||
test("should parse a valid JSON object string", () => {
|
||||
expect(safeParseToolArgs('{"key": "value"}')).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
test("should return {} for a JSON string value", () => {
|
||||
expect(safeParseToolArgs('"hello"')).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON number", () => {
|
||||
expect(safeParseToolArgs("42")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON array", () => {
|
||||
expect(safeParseToolArgs("[1, 2, 3]")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for malformed JSON", () => {
|
||||
expect(safeParseToolArgs("{broken")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON null", () => {
|
||||
expect(safeParseToolArgs("null")).toEqual({});
|
||||
});
|
||||
|
||||
test("should return {} for a JSON boolean", () => {
|
||||
expect(safeParseToolArgs("true")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
|
||||
// Mock navigator for Node 20 environments where it doesn't exist
|
||||
if (typeof globalThis.navigator === "undefined") {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { clipboard: { writeText: vi.fn() } },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
let originalClipboard: Clipboard;
|
||||
|
||||
beforeEach(() => {
|
||||
originalClipboard = navigator.clipboard;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: originalClipboard,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true on successful clipboard write", async () => {
|
||||
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = await copyToClipboard("hello");
|
||||
expect(result).toBe(true);
|
||||
expect(writeTextMock).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("returns false when clipboard API is unavailable", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await copyToClipboard("hello");
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Clipboard API is not available");
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when writeText is not available", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await copyToClipboard("hello");
|
||||
expect(result).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when writeText rejects", async () => {
|
||||
const writeTextMock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Permission denied"));
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await copyToClipboard("hello");
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to copy to clipboard:",
|
||||
expect.any(Error),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,812 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
convertJsonSchemaToZodSchema,
|
||||
actionParametersToJsonSchema,
|
||||
jsonSchemaToActionParameters,
|
||||
JSONSchema,
|
||||
} from "../json-schema";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { Parameter } from "../../types";
|
||||
|
||||
describe("convertJsonSchemaToZodSchema", () => {
|
||||
it("should convert a simple JSON schema to a Zod schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name", "age"],
|
||||
};
|
||||
|
||||
const expectedSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should convert a JSON schema with nested objects to a Zod schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
street: { type: "string" },
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["street", "city"],
|
||||
},
|
||||
},
|
||||
required: ["name", "address"],
|
||||
};
|
||||
|
||||
const expectedSchema = z.object({
|
||||
name: z.string(),
|
||||
address: z.object({
|
||||
street: z.string(),
|
||||
city: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should convert a JSON schema with arrays to a Zod schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
names: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["names"],
|
||||
};
|
||||
|
||||
const expectedSchema = z.object({
|
||||
names: z.array(z.string()),
|
||||
});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should convert a JSON schema with optional properties to a Zod schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number", required: false },
|
||||
},
|
||||
};
|
||||
|
||||
const expectedSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
age: z.number().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, false);
|
||||
|
||||
console.log(convertJsonSchemaToZodSchema(jsonSchema, false));
|
||||
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should convert a JSON schema with different types to a Zod schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
isAdmin: { type: "boolean" },
|
||||
},
|
||||
required: ["name", "age", "isAdmin"],
|
||||
};
|
||||
|
||||
const expectedSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
isAdmin: z.boolean(),
|
||||
});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should handle edge case where JSON schema has no properties", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
};
|
||||
|
||||
const expectedSchema = z.object({});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should preserve string enum constraints", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
description: "The status",
|
||||
enum: ["todo", "done"],
|
||||
},
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const statusSchema = (result as z.ZodObject<any>).shape.status;
|
||||
expect(statusSchema._def.typeName).toBe("ZodEnum");
|
||||
expect(statusSchema._def.values).toEqual(["todo", "done"]);
|
||||
});
|
||||
|
||||
it("should handle null-union type ['string', 'null'] as nullable", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
nickname: {
|
||||
type: ["string", "null"],
|
||||
description: "Optional nickname",
|
||||
},
|
||||
},
|
||||
required: ["nickname"],
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const shape = (result as z.ZodObject<any>).shape;
|
||||
|
||||
// The nickname field should accept both string and null
|
||||
expect(shape.nickname.safeParse("hello").success).toBe(true);
|
||||
expect(shape.nickname.safeParse(null).success).toBe(true);
|
||||
expect(shape.nickname.safeParse(42).success).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle null-union type ['number', 'null'] as nullable number", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
score: {
|
||||
type: ["number", "null"],
|
||||
},
|
||||
},
|
||||
required: ["score"],
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const shape = (result as z.ZodObject<any>).shape;
|
||||
|
||||
expect(shape.score.safeParse(42).success).toBe(true);
|
||||
expect(shape.score.safeParse(null).success).toBe(true);
|
||||
expect(shape.score.safeParse("hello").success).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle edge case where JSON schema has no required properties", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
};
|
||||
|
||||
const expectedSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
age: z.number().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, false);
|
||||
const resultSchemaJson = zodToJsonSchema(result);
|
||||
const expectedSchemaJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultSchemaJson).toStrictEqual(expectedSchemaJson);
|
||||
});
|
||||
|
||||
it("should resolve non-circular $ref definitions correctly", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: { $ref: "#/$defs/Address" },
|
||||
},
|
||||
required: ["address"],
|
||||
$defs: {
|
||||
Address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
street: { type: "string", description: "Street name" },
|
||||
city: { type: "string", description: "City name" },
|
||||
},
|
||||
required: ["street", "city"],
|
||||
description: "A postal address",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const resultJson = zodToJsonSchema(result);
|
||||
|
||||
const expectedSchema = z.object({
|
||||
address: z
|
||||
.object({
|
||||
street: z.string().describe("Street name"),
|
||||
city: z.string().describe("City name"),
|
||||
})
|
||||
.describe("A postal address"),
|
||||
});
|
||||
const expectedJson = zodToJsonSchema(expectedSchema);
|
||||
|
||||
expect(resultJson).toStrictEqual(expectedJson);
|
||||
});
|
||||
|
||||
it("should handle circular $ref without crashing and return z.any()", () => {
|
||||
// A schema where Node references itself — this would cause infinite
|
||||
// recursion without cycle detection.
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
root: { $ref: "#/$defs/Node" },
|
||||
},
|
||||
required: ["root"],
|
||||
$defs: {
|
||||
Node: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string", description: "Node value" },
|
||||
child: { $ref: "#/$defs/Node" },
|
||||
},
|
||||
required: ["value"],
|
||||
description: "A tree node",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// Must not throw or hang
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// The circular ref should have produced a console.warn
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Circular $ref detected"),
|
||||
);
|
||||
|
||||
// The top-level shape should still have a "root" key that is an object
|
||||
const shape = (result as z.ZodObject<any>).shape;
|
||||
expect(shape.root).toBeDefined();
|
||||
|
||||
// Inside root, "value" should be a string and "child" should be z.any()
|
||||
// (child is optional since it's not in required[], so unwrap ZodOptional)
|
||||
const rootShape = (shape.root as z.ZodObject<any>).shape;
|
||||
expect(rootShape.value._def.typeName).toBe("ZodString");
|
||||
const childDef = rootShape.child._def;
|
||||
if (childDef.typeName === "ZodOptional") {
|
||||
expect(childDef.innerType._def.typeName).toBe("ZodAny");
|
||||
} else {
|
||||
expect(childDef.typeName).toBe("ZodAny");
|
||||
}
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should resolve the same $ref used by multiple sibling properties", () => {
|
||||
// Two properties reference the same $def — the visited set must NOT
|
||||
// mark the second usage as circular.
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
billing: { $ref: "#/$defs/Address" },
|
||||
shipping: { $ref: "#/$defs/Address" },
|
||||
},
|
||||
required: ["billing", "shipping"],
|
||||
$defs: {
|
||||
Address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
street: { type: "string", description: "Street" },
|
||||
city: { type: "string", description: "City" },
|
||||
},
|
||||
required: ["street", "city"],
|
||||
description: "An address",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const shape = (result as z.ZodObject<any>).shape;
|
||||
|
||||
// Both should be fully resolved objects, NOT z.any()
|
||||
expect(shape.billing._def.typeName).toBe("ZodObject");
|
||||
expect(shape.shipping._def.typeName).toBe("ZodObject");
|
||||
|
||||
// No circular-ref warning should have been emitted
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should resolve a shared $ref used in different branches of a $ref chain", () => {
|
||||
// Wrapper -> Container (via $ref) which has two children both using $ref to Leaf.
|
||||
// Without set cloning, the second Leaf ref in Container would be wrongly flagged
|
||||
// as circular because the first Leaf resolution already added it to the set.
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
wrapper: { $ref: "#/$defs/Container" },
|
||||
},
|
||||
required: ["wrapper"],
|
||||
$defs: {
|
||||
Container: {
|
||||
type: "object",
|
||||
properties: {
|
||||
first: { $ref: "#/$defs/Leaf" },
|
||||
second: { $ref: "#/$defs/Leaf" },
|
||||
},
|
||||
required: ["first", "second"],
|
||||
description: "A container with two leaves",
|
||||
},
|
||||
Leaf: {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string", description: "Leaf label" },
|
||||
},
|
||||
required: ["label"],
|
||||
description: "A leaf node",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const wrapperShape = (
|
||||
(result as z.ZodObject<any>).shape.wrapper as z.ZodObject<any>
|
||||
).shape;
|
||||
|
||||
// Both first and second should be fully resolved Leaf objects
|
||||
expect(wrapperShape.first._def.typeName).toBe("ZodObject");
|
||||
expect(wrapperShape.second._def.typeName).toBe("ZodObject");
|
||||
|
||||
// No circular-ref warning should have been emitted
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle anyOf with $ref variants", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
pet: {
|
||||
anyOf: [{ $ref: "#/$defs/Cat" }, { $ref: "#/$defs/Dog" }],
|
||||
description: "A pet",
|
||||
},
|
||||
},
|
||||
required: ["pet"],
|
||||
$defs: {
|
||||
Cat: {
|
||||
type: "object",
|
||||
properties: { meow: { type: "boolean" } },
|
||||
required: ["meow"],
|
||||
},
|
||||
Dog: {
|
||||
type: "object",
|
||||
properties: { bark: { type: "boolean" } },
|
||||
required: ["bark"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Should produce a union inside the "pet" property
|
||||
const petSchema = (result as z.ZodObject<any>).shape.pet;
|
||||
expect(petSchema._def.typeName).toBe("ZodUnion");
|
||||
});
|
||||
|
||||
it("should handle integer type as z.number()", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", description: "A count" },
|
||||
},
|
||||
required: ["count"],
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const shape = (result as z.ZodObject<any>).shape;
|
||||
expect(shape.count._def.typeName).toBe("ZodNumber");
|
||||
});
|
||||
|
||||
it("should handle null type", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
empty: { type: "null", description: "Always null" },
|
||||
},
|
||||
required: ["empty"],
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
const shape = (result as z.ZodObject<any>).shape;
|
||||
expect(shape.empty._def.typeName).toBe("ZodNull");
|
||||
});
|
||||
|
||||
it("should warn and return z.any() for unsupported schema types", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const jsonSchema = { type: "custom_unsupported" };
|
||||
const result = convertJsonSchemaToZodSchema(jsonSchema, true);
|
||||
|
||||
expect(result._def.typeName).toBe("ZodAny");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Unsupported JSON schema type "custom_unsupported"',
|
||||
),
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonSchemaToActionParameters", () => {
|
||||
it("should convert a simple JSONSchema to Parameter array", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "User name" },
|
||||
age: { type: "number", description: "User age" },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [
|
||||
{ name: "name", type: "string", description: "User name" },
|
||||
{ name: "age", type: "number", description: "User age", required: false },
|
||||
];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should convert JSONSchema with enum to Parameter array", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["admin", "user", "guest"],
|
||||
description: "User role",
|
||||
},
|
||||
},
|
||||
required: ["role"],
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [
|
||||
{
|
||||
name: "role",
|
||||
type: "string",
|
||||
enum: ["admin", "user", "guest"],
|
||||
description: "User role",
|
||||
},
|
||||
];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should convert nested object JSONSchema to Parameter array", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "User name" },
|
||||
age: { type: "number", description: "User age" },
|
||||
},
|
||||
required: ["name"],
|
||||
description: "User information",
|
||||
},
|
||||
},
|
||||
required: ["user"],
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [
|
||||
{
|
||||
name: "user",
|
||||
type: "object",
|
||||
description: "User information",
|
||||
attributes: [
|
||||
{ name: "name", type: "string", description: "User name" },
|
||||
{
|
||||
name: "age",
|
||||
type: "number",
|
||||
description: "User age",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should convert array JSONSchema to Parameter array", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "User tags",
|
||||
},
|
||||
},
|
||||
required: ["tags"],
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [
|
||||
{ name: "tags", type: "string[]", description: "User tags" },
|
||||
];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should convert object array JSONSchema to Parameter array", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
addresses: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
street: { type: "string", description: "Street name" },
|
||||
city: { type: "string", description: "City name" },
|
||||
},
|
||||
required: ["street"],
|
||||
},
|
||||
description: "User addresses",
|
||||
},
|
||||
},
|
||||
required: ["addresses"],
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [
|
||||
{
|
||||
name: "addresses",
|
||||
type: "object[]",
|
||||
description: "User addresses",
|
||||
attributes: [
|
||||
{ name: "street", type: "string", description: "Street name" },
|
||||
{
|
||||
name: "city",
|
||||
type: "string",
|
||||
description: "City name",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should handle boolean types", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
isAdmin: { type: "boolean", description: "Is user an admin" },
|
||||
},
|
||||
required: ["isAdmin"],
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [
|
||||
{ name: "isAdmin", type: "boolean", description: "Is user an admin" },
|
||||
];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should handle empty object schema", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
};
|
||||
|
||||
const expectedParameters: Parameter[] = [];
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema);
|
||||
expect(result).toEqual(expectedParameters);
|
||||
});
|
||||
|
||||
it("should throw error for nested arrays", () => {
|
||||
const jsonSchema: JSONSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
nestedArray: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
description: "Matrix of strings",
|
||||
},
|
||||
},
|
||||
required: ["nestedArray"],
|
||||
};
|
||||
|
||||
expect(() => jsonSchemaToActionParameters(jsonSchema)).toThrow(
|
||||
"Nested arrays are not supported",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle null-union type ['string', 'null'] as optional string", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
nickname: {
|
||||
type: ["string", "null"],
|
||||
description: "Optional nickname",
|
||||
},
|
||||
},
|
||||
required: ["nickname"],
|
||||
};
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema as any);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "nickname",
|
||||
type: "string",
|
||||
description: "Optional nickname",
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle null-union type ['object', 'null'] preserving properties", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
metadata: {
|
||||
type: ["object", "null"],
|
||||
description: "Optional metadata",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
},
|
||||
required: ["metadata"],
|
||||
};
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema as any);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "metadata",
|
||||
type: "object",
|
||||
description: "Optional metadata",
|
||||
required: false,
|
||||
attributes: [{ name: "key", type: "string", description: undefined }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle null-union type when field is already optional", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
nickname: {
|
||||
type: ["string", "null"],
|
||||
description: "Optional nickname",
|
||||
},
|
||||
},
|
||||
// nickname is NOT in required
|
||||
};
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema as any);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "nickname",
|
||||
type: "string",
|
||||
description: "Optional nickname",
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle type array with only 'null' by falling back to string", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
weird: {
|
||||
type: ["null"],
|
||||
description: "Null-only type",
|
||||
},
|
||||
},
|
||||
required: ["weird"],
|
||||
};
|
||||
|
||||
const result = jsonSchemaToActionParameters(jsonSchema as any);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "weird",
|
||||
type: "string",
|
||||
description: "Null-only type",
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ensure round-trip conversion works", () => {
|
||||
const originalParameters: Parameter[] = [
|
||||
{ name: "name", type: "string", description: "User name" },
|
||||
{ name: "age", type: "number", description: "User age", required: false },
|
||||
{
|
||||
name: "role",
|
||||
type: "string",
|
||||
enum: ["admin", "user"],
|
||||
description: "User role",
|
||||
},
|
||||
{
|
||||
name: "address",
|
||||
type: "object",
|
||||
description: "User address",
|
||||
attributes: [
|
||||
{ name: "street", type: "string", description: "Street name" },
|
||||
{ name: "city", type: "string", description: "City name" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "contacts",
|
||||
type: "object[]",
|
||||
description: "User contacts",
|
||||
attributes: [
|
||||
{ name: "type", type: "string", description: "Contact type" },
|
||||
{ name: "value", type: "string", description: "Contact value" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const jsonSchema = actionParametersToJsonSchema(originalParameters);
|
||||
const roundTripParameters = jsonSchemaToActionParameters(jsonSchema);
|
||||
|
||||
expect(roundTripParameters).toEqual(originalParameters);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Safely copies text to the clipboard.
|
||||
*
|
||||
* Checks that the Clipboard API is available before attempting the write,
|
||||
* and catches any errors (e.g. permission denied, insecure context).
|
||||
*
|
||||
* @param text - The text to copy to the clipboard.
|
||||
* @returns `true` if the text was successfully copied, `false` otherwise.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
console.error("Clipboard API is not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to copy to clipboard:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
export type ComparisonRule =
|
||||
| "EQUALS"
|
||||
| "NOT_EQUALS"
|
||||
| "GREATER_THAN"
|
||||
| "LESS_THAN"
|
||||
| "CONTAINS"
|
||||
| "NOT_CONTAINS"
|
||||
| "MATCHES"
|
||||
| "STARTS_WITH"
|
||||
| "ENDS_WITH";
|
||||
export type LogicalRule = "AND" | "OR" | "NOT";
|
||||
export type ExistenceRule = "EXISTS" | "NOT_EXISTS";
|
||||
|
||||
export type Rule = ComparisonRule | LogicalRule | ExistenceRule;
|
||||
|
||||
export interface BaseCondition {
|
||||
rule: Rule;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ComparisonCondition extends BaseCondition {
|
||||
rule: ComparisonRule;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface LogicalCondition extends BaseCondition {
|
||||
rule: LogicalRule;
|
||||
conditions: Condition[];
|
||||
}
|
||||
|
||||
export interface ExistenceCondition extends BaseCondition {
|
||||
rule: ExistenceRule;
|
||||
}
|
||||
|
||||
export type Condition =
|
||||
| ComparisonCondition
|
||||
| LogicalCondition
|
||||
| ExistenceCondition;
|
||||
|
||||
export function executeConditions({
|
||||
conditions,
|
||||
value,
|
||||
}: {
|
||||
conditions?: Condition[];
|
||||
value: any;
|
||||
}): boolean {
|
||||
// If no conditions, consider it a pass
|
||||
if (!conditions?.length) return true;
|
||||
|
||||
// Run all conditions (implicit AND)
|
||||
return conditions.every((condition) => executeCondition(condition, value));
|
||||
}
|
||||
|
||||
function executeCondition(condition: Condition, value: any): boolean {
|
||||
const targetValue = condition.path
|
||||
? getValueFromPath(value, condition.path)
|
||||
: value;
|
||||
|
||||
switch (condition.rule) {
|
||||
// Logical
|
||||
case "AND":
|
||||
return (condition as LogicalCondition).conditions.every((c) =>
|
||||
executeCondition(c, value),
|
||||
);
|
||||
case "OR":
|
||||
return (condition as LogicalCondition).conditions.some((c) =>
|
||||
executeCondition(c, value),
|
||||
);
|
||||
case "NOT":
|
||||
return !(condition as LogicalCondition).conditions.every((c) =>
|
||||
executeCondition(c, value),
|
||||
);
|
||||
|
||||
// Comparison
|
||||
case "EQUALS":
|
||||
return targetValue === (condition as ComparisonCondition).value;
|
||||
case "NOT_EQUALS":
|
||||
return targetValue !== (condition as ComparisonCondition).value;
|
||||
case "GREATER_THAN":
|
||||
return targetValue > (condition as ComparisonCondition).value;
|
||||
case "LESS_THAN":
|
||||
return targetValue < (condition as ComparisonCondition).value;
|
||||
case "CONTAINS":
|
||||
return (
|
||||
Array.isArray(targetValue) &&
|
||||
targetValue.includes((condition as ComparisonCondition).value)
|
||||
);
|
||||
case "NOT_CONTAINS":
|
||||
return (
|
||||
Array.isArray(targetValue) &&
|
||||
!targetValue.includes((condition as ComparisonCondition).value)
|
||||
);
|
||||
case "MATCHES":
|
||||
return new RegExp((condition as ComparisonCondition).value).test(
|
||||
String(targetValue),
|
||||
);
|
||||
case "STARTS_WITH":
|
||||
return String(targetValue).startsWith(
|
||||
(condition as ComparisonCondition).value,
|
||||
);
|
||||
case "ENDS_WITH":
|
||||
return String(targetValue).endsWith(
|
||||
(condition as ComparisonCondition).value,
|
||||
);
|
||||
|
||||
// Existence
|
||||
case "EXISTS":
|
||||
return targetValue !== undefined && targetValue !== null;
|
||||
case "NOT_EXISTS":
|
||||
return targetValue === undefined || targetValue === null;
|
||||
}
|
||||
}
|
||||
|
||||
function getValueFromPath(obj: any, path: string): any {
|
||||
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Console styling utilities for CopilotKit branded messages
|
||||
* Provides consistent, readable colors across light and dark console themes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Color palette optimized for console readability
|
||||
*/
|
||||
export const ConsoleColors = {
|
||||
/** Primary brand blue - for titles and links */
|
||||
primary: "#007acc",
|
||||
/** Success green - for positive messaging */
|
||||
success: "#22c55e",
|
||||
/** Purple - for feature highlights */
|
||||
feature: "#a855f7",
|
||||
/** Red - for calls-to-action */
|
||||
cta: "#ef4444",
|
||||
/** Cyan - for closing statements */
|
||||
info: "#06b6d4",
|
||||
/** Inherit console default - for body text */
|
||||
inherit: "inherit",
|
||||
/** Warning style */
|
||||
warning: "#f59e0b",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Console style templates for common patterns
|
||||
*/
|
||||
export const ConsoleStyles = {
|
||||
/** Large header style */
|
||||
header: `color: ${ConsoleColors.warning}; font-weight: bold; font-size: 16px;`,
|
||||
/** Section header style */
|
||||
section: `color: ${ConsoleColors.success}; font-weight: bold;`,
|
||||
/** Feature highlight style */
|
||||
highlight: `color: ${ConsoleColors.feature}; font-weight: bold;`,
|
||||
/** Call-to-action style */
|
||||
cta: `color: ${ConsoleColors.success}; font-weight: bold;`,
|
||||
/** Info style */
|
||||
info: `color: ${ConsoleColors.info}; font-weight: bold;`,
|
||||
/** Link style */
|
||||
link: `color: ${ConsoleColors.primary}; text-decoration: underline;`,
|
||||
/** Body text - inherits console theme */
|
||||
body: `color: ${ConsoleColors.inherit};`,
|
||||
/** Warning style */
|
||||
warning: `color: ${ConsoleColors.cta}; font-weight: bold;`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Styled console message for CopilotKit Platform promotion
|
||||
* Displays a beautiful, branded advertisement in the console
|
||||
*/
|
||||
export function logCopilotKitPlatformMessage() {
|
||||
console.log(
|
||||
`%cCopilotKit Warning%c
|
||||
|
||||
useCopilotChatHeadless_c provides full compatibility with CopilotKit's newly released Headless UI feature set. To enable this premium feature, add your public license key, available for free at:
|
||||
|
||||
%chttps://dashboard.operations.copilotkit.ai%c
|
||||
|
||||
Alternatively, useCopilotChat is available for basic programmatic control, and does not require a license key.
|
||||
|
||||
To learn more about premium features, read the documentation here:
|
||||
|
||||
%chttps://docs.copilotkit.ai/premium/overview%c`,
|
||||
ConsoleStyles.header,
|
||||
ConsoleStyles.body,
|
||||
ConsoleStyles.cta,
|
||||
ConsoleStyles.body,
|
||||
ConsoleStyles.link,
|
||||
ConsoleStyles.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function publicApiKeyRequired(feature: string) {
|
||||
console.log(
|
||||
`
|
||||
%cCopilotKit Warning%c \n
|
||||
In order to use ${feature}, you need to add your CopilotKit license key, available for free at https://dashboard.operations.copilotkit.ai.
|
||||
`.trim(),
|
||||
ConsoleStyles.header,
|
||||
ConsoleStyles.body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled console message with custom content
|
||||
*
|
||||
* @param template - Template string with %c placeholders
|
||||
* @param styles - Array of style strings matching the %c placeholders
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* logStyled(
|
||||
* '%cCopilotKit%c Welcome to the platform!',
|
||||
* [ConsoleStyles.header, ConsoleStyles.body]
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function logStyled(template: string, styles: string[]) {
|
||||
console.log(template, ...styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick styled console methods for common use cases
|
||||
*/
|
||||
export const styledConsole = {
|
||||
/** Log a success message */
|
||||
success: (message: string) =>
|
||||
logStyled(`%c✅ ${message}`, [ConsoleStyles.section]),
|
||||
|
||||
/** Log an info message */
|
||||
info: (message: string) => logStyled(`%cℹ️ ${message}`, [ConsoleStyles.info]),
|
||||
|
||||
/** Log a feature highlight */
|
||||
feature: (message: string) =>
|
||||
logStyled(`%c✨ ${message}`, [ConsoleStyles.highlight]),
|
||||
|
||||
/** Log a call-to-action */
|
||||
cta: (message: string) => logStyled(`%c🚀 ${message}`, [ConsoleStyles.cta]),
|
||||
|
||||
/** Log the CopilotKit platform promotion */
|
||||
logCopilotKitPlatformMessage: logCopilotKitPlatformMessage,
|
||||
|
||||
/** Log a `publicApiKeyRequired` warning */
|
||||
publicApiKeyRequired: publicApiKeyRequired,
|
||||
} as const;
|
||||
@@ -0,0 +1,558 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { COPILOTKIT_VERSION } from "../index";
|
||||
|
||||
export enum Severity {
|
||||
CRITICAL = "critical", // Critical errors that block core functionality
|
||||
WARNING = "warning", // Configuration/setup issues that need attention
|
||||
INFO = "info", // General errors and network issues
|
||||
}
|
||||
|
||||
export enum ErrorVisibility {
|
||||
BANNER = "banner", // Critical errors shown as fixed banners
|
||||
TOAST = "toast", // Regular errors shown as dismissible toasts
|
||||
SILENT = "silent", // Errors logged but not shown to user
|
||||
DEV_ONLY = "dev_only", // Errors only shown in development mode
|
||||
}
|
||||
|
||||
export const ERROR_NAMES = {
|
||||
COPILOT_ERROR: "CopilotError",
|
||||
COPILOT_API_DISCOVERY_ERROR: "CopilotApiDiscoveryError",
|
||||
COPILOT_REMOTE_ENDPOINT_DISCOVERY_ERROR:
|
||||
"CopilotKitRemoteEndpointDiscoveryError",
|
||||
COPILOT_KIT_AGENT_DISCOVERY_ERROR: "CopilotKitAgentDiscoveryError",
|
||||
COPILOT_KIT_LOW_LEVEL_ERROR: "CopilotKitLowLevelError",
|
||||
COPILOT_KIT_VERSION_MISMATCH_ERROR: "CopilotKitVersionMismatchError",
|
||||
RESOLVED_COPILOT_KIT_ERROR: "ResolvedCopilotKitError",
|
||||
CONFIGURATION_ERROR: "ConfigurationError",
|
||||
MISSING_PUBLIC_API_KEY_ERROR: "MissingPublicApiKeyError",
|
||||
UPGRADE_REQUIRED_ERROR: "UpgradeRequiredError",
|
||||
} as const;
|
||||
|
||||
// Banner errors - critical configuration/discovery issues
|
||||
export const BANNER_ERROR_NAMES = [
|
||||
ERROR_NAMES.CONFIGURATION_ERROR,
|
||||
ERROR_NAMES.MISSING_PUBLIC_API_KEY_ERROR,
|
||||
ERROR_NAMES.UPGRADE_REQUIRED_ERROR,
|
||||
ERROR_NAMES.COPILOT_API_DISCOVERY_ERROR,
|
||||
ERROR_NAMES.COPILOT_REMOTE_ENDPOINT_DISCOVERY_ERROR,
|
||||
ERROR_NAMES.COPILOT_KIT_AGENT_DISCOVERY_ERROR,
|
||||
];
|
||||
|
||||
// Legacy cloud error names for backward compatibility
|
||||
export const COPILOT_CLOUD_ERROR_NAMES = BANNER_ERROR_NAMES;
|
||||
|
||||
export enum CopilotKitErrorCode {
|
||||
NETWORK_ERROR = "NETWORK_ERROR",
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
AGENT_NOT_FOUND = "AGENT_NOT_FOUND",
|
||||
API_NOT_FOUND = "API_NOT_FOUND",
|
||||
REMOTE_ENDPOINT_NOT_FOUND = "REMOTE_ENDPOINT_NOT_FOUND",
|
||||
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR",
|
||||
MISUSE = "MISUSE",
|
||||
UNKNOWN = "UNKNOWN",
|
||||
VERSION_MISMATCH = "VERSION_MISMATCH",
|
||||
CONFIGURATION_ERROR = "CONFIGURATION_ERROR",
|
||||
MISSING_PUBLIC_API_KEY_ERROR = "MISSING_PUBLIC_API_KEY_ERROR",
|
||||
UPGRADE_REQUIRED_ERROR = "UPGRADE_REQUIRED_ERROR",
|
||||
}
|
||||
|
||||
const BASE_URL = "https://docs.copilotkit.ai";
|
||||
|
||||
const getSeeMoreMarkdown = (link: string) => `See more: [${link}](${link})`;
|
||||
|
||||
export const ERROR_CONFIG = {
|
||||
[CopilotKitErrorCode.NETWORK_ERROR]: {
|
||||
statusCode: 503,
|
||||
troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#network-errors-api-not-found`,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.NOT_FOUND]: {
|
||||
statusCode: 404,
|
||||
troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#network-errors-api-not-found`,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.AGENT_NOT_FOUND]: {
|
||||
statusCode: 500,
|
||||
troubleshootingUrl: `${BASE_URL}/langgraph-python/coagent-troubleshooting/common-coagent-issues#i-am-getting-agent-not-found-error`,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.API_NOT_FOUND]: {
|
||||
statusCode: 404,
|
||||
troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#network-errors-api-not-found`,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.REMOTE_ENDPOINT_NOT_FOUND]: {
|
||||
statusCode: 404,
|
||||
troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#remote-endpoint-not-found-error`,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.AUTHENTICATION_ERROR]: {
|
||||
statusCode: 401,
|
||||
troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues`,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.MISUSE]: {
|
||||
statusCode: 400,
|
||||
troubleshootingUrl: null,
|
||||
visibility: ErrorVisibility.DEV_ONLY,
|
||||
severity: Severity.WARNING,
|
||||
},
|
||||
[CopilotKitErrorCode.UNKNOWN]: {
|
||||
statusCode: 500,
|
||||
visibility: ErrorVisibility.TOAST,
|
||||
severity: Severity.CRITICAL,
|
||||
},
|
||||
[CopilotKitErrorCode.CONFIGURATION_ERROR]: {
|
||||
statusCode: 400,
|
||||
troubleshootingUrl: null,
|
||||
severity: Severity.WARNING,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
},
|
||||
[CopilotKitErrorCode.MISSING_PUBLIC_API_KEY_ERROR]: {
|
||||
statusCode: 400,
|
||||
troubleshootingUrl: null,
|
||||
severity: Severity.CRITICAL,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
},
|
||||
[CopilotKitErrorCode.UPGRADE_REQUIRED_ERROR]: {
|
||||
statusCode: 402,
|
||||
troubleshootingUrl: null,
|
||||
severity: Severity.WARNING,
|
||||
visibility: ErrorVisibility.BANNER,
|
||||
},
|
||||
[CopilotKitErrorCode.VERSION_MISMATCH]: {
|
||||
statusCode: 400,
|
||||
troubleshootingUrl: null,
|
||||
visibility: ErrorVisibility.DEV_ONLY,
|
||||
severity: Severity.INFO,
|
||||
},
|
||||
};
|
||||
|
||||
export class CopilotKitError extends GraphQLError {
|
||||
code: CopilotKitErrorCode;
|
||||
statusCode: number;
|
||||
severity?: Severity;
|
||||
visibility: ErrorVisibility;
|
||||
|
||||
constructor({
|
||||
message = "Unknown error occurred",
|
||||
code,
|
||||
severity,
|
||||
visibility,
|
||||
}: {
|
||||
message?: string;
|
||||
code: CopilotKitErrorCode;
|
||||
severity?: Severity;
|
||||
visibility?: ErrorVisibility;
|
||||
}) {
|
||||
const name = ERROR_NAMES.COPILOT_ERROR;
|
||||
const config = ERROR_CONFIG[code];
|
||||
const { statusCode } = config;
|
||||
const resolvedVisibility =
|
||||
visibility ?? config.visibility ?? ErrorVisibility.TOAST;
|
||||
const resolvedSeverity =
|
||||
severity ?? ("severity" in config ? config.severity : undefined);
|
||||
|
||||
super(message, {
|
||||
extensions: {
|
||||
name,
|
||||
statusCode,
|
||||
code,
|
||||
visibility: resolvedVisibility,
|
||||
severity: resolvedSeverity,
|
||||
troubleshootingUrl:
|
||||
"troubleshootingUrl" in config ? config.troubleshootingUrl : null,
|
||||
originalError: {
|
||||
message,
|
||||
stack: new Error().stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
this.statusCode = statusCode;
|
||||
this.severity = resolvedSeverity;
|
||||
this.visibility = resolvedVisibility;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when we can identify wrong usage of our components.
|
||||
* This helps us notify the developer before real errors can happen
|
||||
*
|
||||
* @extends CopilotKitError
|
||||
*/
|
||||
export class CopilotKitMisuseError extends CopilotKitError {
|
||||
constructor({
|
||||
message,
|
||||
code = CopilotKitErrorCode.MISUSE,
|
||||
}: {
|
||||
message: string;
|
||||
code?: CopilotKitErrorCode;
|
||||
}) {
|
||||
const docsLink =
|
||||
"troubleshootingUrl" in ERROR_CONFIG[code] &&
|
||||
ERROR_CONFIG[code].troubleshootingUrl
|
||||
? getSeeMoreMarkdown(ERROR_CONFIG[code].troubleshootingUrl as string)
|
||||
: null;
|
||||
const finalMessage = docsLink ? `${message}.\n\n${docsLink}` : message;
|
||||
super({ message: finalMessage, code });
|
||||
this.name = ERROR_NAMES.COPILOT_API_DISCOVERY_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
const getVersionMismatchErrorMessage = ({
|
||||
reactCoreVersion,
|
||||
runtimeVersion,
|
||||
runtimeClientGqlVersion,
|
||||
}: VersionMismatchResponse) =>
|
||||
`Version mismatch detected: @copilotkit/runtime@${runtimeVersion ?? ""} is not compatible with @copilotkit/react-core@${reactCoreVersion} and @copilotkit/runtime-client-gql@${runtimeClientGqlVersion}. Please ensure all installed copilotkit packages are on the same version.`;
|
||||
/**
|
||||
* Error thrown when CPK versions does not match
|
||||
*
|
||||
* @extends CopilotKitError
|
||||
*/
|
||||
export class CopilotKitVersionMismatchError extends CopilotKitError {
|
||||
constructor({
|
||||
reactCoreVersion,
|
||||
runtimeVersion,
|
||||
runtimeClientGqlVersion,
|
||||
}: VersionMismatchResponse) {
|
||||
const code = CopilotKitErrorCode.VERSION_MISMATCH;
|
||||
super({
|
||||
message: getVersionMismatchErrorMessage({
|
||||
reactCoreVersion,
|
||||
runtimeVersion,
|
||||
runtimeClientGqlVersion,
|
||||
}),
|
||||
code,
|
||||
});
|
||||
this.name = ERROR_NAMES.COPILOT_KIT_VERSION_MISMATCH_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when the CopilotKit API endpoint cannot be discovered or accessed.
|
||||
* This typically occurs when:
|
||||
* - The API endpoint URL is invalid or misconfigured
|
||||
* - The API service is not running at the expected location
|
||||
* - There are network/firewall issues preventing access
|
||||
*
|
||||
* @extends CopilotKitError
|
||||
*/
|
||||
export class CopilotKitApiDiscoveryError extends CopilotKitError {
|
||||
constructor(
|
||||
params: {
|
||||
message?: string;
|
||||
code?:
|
||||
| CopilotKitErrorCode.API_NOT_FOUND
|
||||
| CopilotKitErrorCode.REMOTE_ENDPOINT_NOT_FOUND;
|
||||
url?: string;
|
||||
} = {},
|
||||
) {
|
||||
const url = params.url ?? "";
|
||||
let operationSuffix = "";
|
||||
if (url?.includes("/info"))
|
||||
operationSuffix = `when fetching CopilotKit info`;
|
||||
else if (url.includes("/actions/execute"))
|
||||
operationSuffix = `when attempting to execute actions.`;
|
||||
else if (url.includes("/agents/state"))
|
||||
operationSuffix = `when attempting to get agent state.`;
|
||||
else if (url.includes("/agents/execute"))
|
||||
operationSuffix = `when attempting to execute agent(s).`;
|
||||
const message =
|
||||
params.message ??
|
||||
(params.url
|
||||
? `Failed to find CopilotKit API endpoint at url ${params.url} ${operationSuffix}`
|
||||
: `Failed to find CopilotKit API endpoint.`);
|
||||
const code = params.code ?? CopilotKitErrorCode.API_NOT_FOUND;
|
||||
const errorMessage = `${message}.\n\n${getSeeMoreMarkdown(ERROR_CONFIG[code].troubleshootingUrl)}`;
|
||||
super({ message: errorMessage, code });
|
||||
this.name = ERROR_NAMES.COPILOT_API_DISCOVERY_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is used for endpoints specified in runtime's remote endpoints. If they cannot be contacted
|
||||
* This typically occurs when:
|
||||
* - The API endpoint URL is invalid or misconfigured
|
||||
* - The API service is not running at the expected location
|
||||
*
|
||||
* @extends CopilotKitApiDiscoveryError
|
||||
*/
|
||||
export class CopilotKitRemoteEndpointDiscoveryError extends CopilotKitApiDiscoveryError {
|
||||
constructor(params?: { message?: string; url?: string }) {
|
||||
const message =
|
||||
params?.message ??
|
||||
(params?.url
|
||||
? `Failed to find or contact remote endpoint at url ${params.url}`
|
||||
: "Failed to find or contact remote endpoint");
|
||||
const code = CopilotKitErrorCode.REMOTE_ENDPOINT_NOT_FOUND;
|
||||
super({ message, code });
|
||||
this.name = ERROR_NAMES.COPILOT_REMOTE_ENDPOINT_DISCOVERY_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a LangGraph agent cannot be found or accessed.
|
||||
* This typically occurs when:
|
||||
* - The specified agent name does not exist in the deployment
|
||||
* - The agent configuration is invalid or missing
|
||||
* - The agent service is not properly deployed or initialized
|
||||
*
|
||||
* @extends CopilotKitError
|
||||
*/
|
||||
export class CopilotKitAgentDiscoveryError extends CopilotKitError {
|
||||
constructor(params: {
|
||||
agentName?: string;
|
||||
availableAgents: { name: string; id: string }[];
|
||||
}) {
|
||||
const { agentName, availableAgents } = params;
|
||||
const code = CopilotKitErrorCode.AGENT_NOT_FOUND;
|
||||
|
||||
const seeMore = getSeeMoreMarkdown(ERROR_CONFIG[code].troubleshootingUrl);
|
||||
let message;
|
||||
|
||||
if (availableAgents.length) {
|
||||
const agentList = availableAgents.map((agent) => agent.name).join(", ");
|
||||
|
||||
if (agentName) {
|
||||
message = `Agent '${agentName}' was not found. Available agents are: ${agentList}. Please verify the agent name in your configuration and ensure it matches one of the available agents.\n\n${seeMore}`;
|
||||
} else {
|
||||
message = `The requested agent was not found. Available agents are: ${agentList}. Please verify the agent name in your configuration and ensure it matches one of the available agents.\n\n${seeMore}`;
|
||||
}
|
||||
} else {
|
||||
message = `${agentName ? `Agent '${agentName}'` : "The requested agent"} was not found. Please set up at least one agent before proceeding. ${seeMore}`;
|
||||
}
|
||||
|
||||
super({ message, code });
|
||||
this.name = ERROR_NAMES.COPILOT_KIT_AGENT_DISCOVERY_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles low-level networking errors that occur before a request reaches the server.
|
||||
* These errors arise from issues in the underlying communication infrastructure rather than
|
||||
* application-level logic or server responses. Typically used to handle "fetch failed" errors
|
||||
* where no HTTP status code is available.
|
||||
*
|
||||
* Common scenarios include:
|
||||
* - Connection failures (ECONNREFUSED) when server is down/unreachable
|
||||
* - DNS resolution failures (ENOTFOUND) when domain can't be resolved
|
||||
* - Timeouts (ETIMEDOUT) when request takes too long
|
||||
* - Protocol/transport layer errors like SSL/TLS issues
|
||||
*/
|
||||
export class CopilotKitLowLevelError extends CopilotKitError {
|
||||
constructor({
|
||||
error,
|
||||
url,
|
||||
message,
|
||||
}: {
|
||||
error: Error;
|
||||
url: string;
|
||||
message?: string;
|
||||
}) {
|
||||
let code = CopilotKitErrorCode.NETWORK_ERROR;
|
||||
|
||||
// @ts-expect-error -- code may exist
|
||||
const errorCode = error.code as string;
|
||||
const errorMessage =
|
||||
message ?? resolveLowLevelErrorMessage({ errorCode, url });
|
||||
|
||||
super({ message: errorMessage, code });
|
||||
|
||||
this.name = ERROR_NAMES.COPILOT_KIT_LOW_LEVEL_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic catch-all error handler for HTTP responses from the CopilotKit API where a status code is available.
|
||||
* Used when we receive an HTTP error status and wish to handle broad range of them
|
||||
*
|
||||
* This differs from CopilotKitLowLevelError in that:
|
||||
* - ResolvedCopilotKitError: Server was reached and returned an HTTP status
|
||||
* - CopilotKitLowLevelError: Error occurred before reaching server (e.g. network failure)
|
||||
*
|
||||
* @param status - The HTTP status code received from the API response
|
||||
* @param message - Optional error message to include
|
||||
* @param code - Optional specific CopilotKitErrorCode to override default behavior
|
||||
*
|
||||
* Default behavior:
|
||||
* - 400 Bad Request: Maps to CopilotKitApiDiscoveryError
|
||||
* - All other status codes: Maps to UNKNOWN error code if no specific code provided
|
||||
*/
|
||||
export class ResolvedCopilotKitError extends CopilotKitError {
|
||||
constructor({
|
||||
status,
|
||||
message,
|
||||
code,
|
||||
isRemoteEndpoint,
|
||||
url,
|
||||
}: {
|
||||
status: number;
|
||||
message?: string;
|
||||
code?: CopilotKitErrorCode;
|
||||
isRemoteEndpoint?: boolean;
|
||||
url?: string;
|
||||
}) {
|
||||
let resolvedCode = code;
|
||||
if (!resolvedCode) {
|
||||
switch (status) {
|
||||
case 400:
|
||||
throw new CopilotKitApiDiscoveryError({ message, url });
|
||||
case 404:
|
||||
throw isRemoteEndpoint
|
||||
? new CopilotKitRemoteEndpointDiscoveryError({ message, url })
|
||||
: new CopilotKitApiDiscoveryError({ message, url });
|
||||
default:
|
||||
resolvedCode = CopilotKitErrorCode.UNKNOWN;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
super({ message, code: resolvedCode });
|
||||
this.name = ERROR_NAMES.RESOLVED_COPILOT_KIT_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurationError extends CopilotKitError {
|
||||
constructor(message: string) {
|
||||
super({ message, code: CopilotKitErrorCode.CONFIGURATION_ERROR });
|
||||
this.name = ERROR_NAMES.CONFIGURATION_ERROR;
|
||||
this.severity = Severity.WARNING;
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingPublicApiKeyError extends ConfigurationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = ERROR_NAMES.MISSING_PUBLIC_API_KEY_ERROR;
|
||||
this.severity = Severity.CRITICAL;
|
||||
}
|
||||
}
|
||||
|
||||
export class UpgradeRequiredError extends ConfigurationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = ERROR_NAMES.UPGRADE_REQUIRED_ERROR;
|
||||
this.severity = Severity.WARNING;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is already a structured CopilotKit error.
|
||||
* This utility centralizes the logic for detecting structured errors across the codebase.
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns true if the error is already structured, false otherwise
|
||||
*/
|
||||
export function isStructuredCopilotKitError(error: any): boolean {
|
||||
return (
|
||||
error instanceof CopilotKitError ||
|
||||
error instanceof CopilotKitLowLevelError ||
|
||||
(error?.name && error.name.includes("CopilotKit")) ||
|
||||
error?.extensions?.code !== undefined // Check if it has our structured error properties
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error as-is if it's already structured, otherwise converts it using the provided converter function.
|
||||
* This utility centralizes the pattern of preserving structured errors while converting unstructured ones.
|
||||
*
|
||||
* @param error - The error to process
|
||||
* @param converter - Function to convert unstructured errors to structured ones
|
||||
* @returns The structured error
|
||||
*/
|
||||
export function ensureStructuredError<T extends CopilotKitError>(
|
||||
error: any,
|
||||
converter: (error: any) => T,
|
||||
): T | any {
|
||||
return isStructuredCopilotKitError(error) ? error : converter(error);
|
||||
}
|
||||
|
||||
interface VersionMismatchResponse {
|
||||
runtimeVersion?: string;
|
||||
runtimeClientGqlVersion: string;
|
||||
reactCoreVersion: string;
|
||||
}
|
||||
|
||||
export async function getPossibleVersionMismatch({
|
||||
runtimeVersion,
|
||||
runtimeClientGqlVersion,
|
||||
}: {
|
||||
runtimeVersion?: string;
|
||||
runtimeClientGqlVersion: string;
|
||||
}) {
|
||||
if (!runtimeVersion || runtimeVersion === "" || !runtimeClientGqlVersion)
|
||||
return;
|
||||
if (
|
||||
COPILOTKIT_VERSION !== runtimeVersion ||
|
||||
COPILOTKIT_VERSION !== runtimeClientGqlVersion ||
|
||||
runtimeVersion !== runtimeClientGqlVersion
|
||||
) {
|
||||
return {
|
||||
runtimeVersion,
|
||||
runtimeClientGqlVersion,
|
||||
reactCoreVersion: COPILOTKIT_VERSION,
|
||||
message: getVersionMismatchErrorMessage({
|
||||
runtimeVersion,
|
||||
runtimeClientGqlVersion,
|
||||
reactCoreVersion: COPILOTKIT_VERSION,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const resolveLowLevelErrorMessage = ({
|
||||
errorCode,
|
||||
url,
|
||||
}: {
|
||||
errorCode?: string;
|
||||
url: string;
|
||||
}) => {
|
||||
const troubleshootingLink =
|
||||
ERROR_CONFIG[CopilotKitErrorCode.NETWORK_ERROR].troubleshootingUrl;
|
||||
const genericMessage = (
|
||||
description = `Failed to fetch from url ${url}.`,
|
||||
) => `${description}.
|
||||
|
||||
Possible reasons:
|
||||
- -The server may have an error preventing it from returning a response (Check the server logs for more info).
|
||||
- -The server might be down or unreachable
|
||||
- -There might be a network issue (e.g., DNS failure, connection timeout)
|
||||
- -The URL might be incorrect
|
||||
- -The server is not running on the specified port
|
||||
|
||||
${getSeeMoreMarkdown(troubleshootingLink)}`;
|
||||
|
||||
if (url.includes("/info"))
|
||||
return genericMessage(
|
||||
`Failed to fetch CopilotKit agents/action information from url ${url}.`,
|
||||
);
|
||||
if (url.includes("/actions/execute"))
|
||||
return genericMessage(`Fetch call to ${url} to execute actions failed.`);
|
||||
if (url.includes("/agents/state"))
|
||||
return genericMessage(`Fetch call to ${url} to get agent state failed.`);
|
||||
if (url.includes("/agents/execute"))
|
||||
return genericMessage(`Fetch call to ${url} to execute agent(s) failed.`);
|
||||
|
||||
switch (errorCode) {
|
||||
case "ECONNREFUSED":
|
||||
return `Connection to ${url} was refused. Ensure the server is running and accessible.\n\n${getSeeMoreMarkdown(troubleshootingLink)}`;
|
||||
case "ENOTFOUND":
|
||||
return `The server on ${url} could not be found. Check the URL or your network configuration.\n\n${getSeeMoreMarkdown(ERROR_CONFIG[CopilotKitErrorCode.NOT_FOUND].troubleshootingUrl)}`;
|
||||
case "ETIMEDOUT":
|
||||
return `The connection to ${url} timed out. The server might be overloaded or taking too long to respond.\n\n${getSeeMoreMarkdown(troubleshootingLink)}`;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
export * from "./clipboard";
|
||||
export * from "./conditions";
|
||||
export * from "./console-styling";
|
||||
export * from "./errors";
|
||||
export * from "./json-schema";
|
||||
export * from "./types";
|
||||
export * from "./random-id";
|
||||
export * from "./requests";
|
||||
|
||||
import * as PartialJSON from "partial-json";
|
||||
|
||||
/**
|
||||
* Safely parses a JSON string into an object
|
||||
* @param json The JSON string to parse
|
||||
* @param fallback Optional fallback value to return if parsing fails. If not provided or set to "unset", returns null
|
||||
* @returns The parsed JSON object, or the fallback value (or null) if parsing fails
|
||||
*/
|
||||
export function parseJson(json: string, fallback: any = "unset") {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
return fallback === "unset" ? null : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a partial/incomplete JSON string, returning as much valid data as possible.
|
||||
* Falls back to an empty object if parsing fails entirely.
|
||||
*/
|
||||
export function partialJSONParse(json: string) {
|
||||
try {
|
||||
const parsed = PartialJSON.parse(json);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an exponential backoff function suitable for Phoenix.js
|
||||
* `reconnectAfterMs` and `rejoinAfterMs` options.
|
||||
*
|
||||
* @param baseMs - Initial delay for the first retry attempt.
|
||||
* @param maxMs - Upper bound — delays are capped at this value.
|
||||
*
|
||||
* Phoenix calls the returned function with a 1-based `tries` count.
|
||||
* The delay doubles on each attempt: baseMs, 2×baseMs, 4×baseMs, …, maxMs.
|
||||
*/
|
||||
export function phoenixExponentialBackoff(
|
||||
baseMs: number,
|
||||
maxMs: number,
|
||||
): (tries: number) => number {
|
||||
return (tries: number) => Math.min(baseMs * 2 ** (tries - 1), maxMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an array of items to a new array, skipping items that throw errors during mapping
|
||||
* @param items The array to map
|
||||
* @param callback The mapping function to apply to each item
|
||||
* @returns A new array containing only the successfully mapped items
|
||||
*/
|
||||
export function tryMap<TItem, TMapped>(
|
||||
items: TItem[],
|
||||
callback: (item: TItem, index: number, array: TItem[]) => TMapped,
|
||||
): TMapped[] {
|
||||
return items.reduce<TMapped[]>((acc, item, index, array) => {
|
||||
try {
|
||||
acc.push(callback(item, index, array));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current environment is macOS
|
||||
* @returns {boolean} True if running on macOS, false otherwise
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return /Mac|iMac|Macintosh/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a JSON string into a tool arguments object.
|
||||
* Returns the parsed object only if it's a plain object (not an array, null, etc.).
|
||||
* Falls back to an empty object for any non-object JSON value or parse failure.
|
||||
*/
|
||||
export function safeParseToolArgs(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
console.warn(
|
||||
`[CopilotKit] Tool arguments parsed to non-object (${typeof parsed}), falling back to empty object`,
|
||||
);
|
||||
return {};
|
||||
} catch {
|
||||
console.warn(
|
||||
"[CopilotKit] Failed to parse tool arguments, falling back to empty object",
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { z } from "zod";
|
||||
import { Parameter } from "../types";
|
||||
|
||||
export type JSONSchemaString = {
|
||||
type: "string";
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
};
|
||||
|
||||
export type JSONSchemaNumber = {
|
||||
type: "number";
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type JSONSchemaBoolean = {
|
||||
type: "boolean";
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type JSONSchemaObject = {
|
||||
type: "object";
|
||||
properties?: Record<string, JSONSchema>;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type JSONSchemaArray = {
|
||||
type: "array";
|
||||
items: JSONSchema;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type JSONSchema =
|
||||
| JSONSchemaString
|
||||
| JSONSchemaNumber
|
||||
| JSONSchemaBoolean
|
||||
| JSONSchemaObject
|
||||
| JSONSchemaArray;
|
||||
|
||||
export function actionParametersToJsonSchema(
|
||||
actionParameters: Parameter[],
|
||||
): JSONSchema {
|
||||
// Create the parameters object based on the argumentAnnotations
|
||||
let parameters: { [key: string]: any } = {};
|
||||
for (let parameter of actionParameters || []) {
|
||||
parameters[parameter.name] = convertAttribute(parameter);
|
||||
}
|
||||
|
||||
let requiredParameterNames: string[] = [];
|
||||
for (let arg of actionParameters || []) {
|
||||
if (arg.required !== false) {
|
||||
requiredParameterNames.push(arg.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the ChatCompletionFunctions object
|
||||
return {
|
||||
type: "object",
|
||||
properties: parameters,
|
||||
required: requiredParameterNames,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert JSONSchema to Parameter[]
|
||||
export function jsonSchemaToActionParameters(
|
||||
jsonSchema: JSONSchema,
|
||||
): Parameter[] {
|
||||
if (jsonSchema.type !== "object" || !jsonSchema.properties) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parameters: Parameter[] = [];
|
||||
const requiredFields = jsonSchema.required || [];
|
||||
|
||||
for (const [name, schema] of Object.entries(jsonSchema.properties)) {
|
||||
const parameter = convertJsonSchemaToParameter(
|
||||
name,
|
||||
schema,
|
||||
requiredFields.includes(name),
|
||||
);
|
||||
parameters.push(parameter);
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// Convert JSONSchema property to Parameter
|
||||
function convertJsonSchemaToParameter(
|
||||
name: string,
|
||||
schema: JSONSchema,
|
||||
isRequired: boolean,
|
||||
): Parameter {
|
||||
const baseParameter: Parameter = {
|
||||
name,
|
||||
description: schema.description,
|
||||
};
|
||||
|
||||
if (!isRequired) {
|
||||
baseParameter.required = false;
|
||||
}
|
||||
|
||||
// Handle null-union types like ["string", "null"] by picking the non-null type
|
||||
if (Array.isArray(schema.type)) {
|
||||
const types = schema.type as string[];
|
||||
const hasNull = types.includes("null");
|
||||
const nonNullTypes = types.filter((t: string) => t !== "null");
|
||||
const resolvedType = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
|
||||
return convertJsonSchemaToParameter(
|
||||
name,
|
||||
{ ...schema, type: resolvedType } as JSONSchema,
|
||||
hasNull ? false : isRequired,
|
||||
);
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return {
|
||||
...baseParameter,
|
||||
type: "string",
|
||||
...(schema.enum && { enum: schema.enum }),
|
||||
};
|
||||
case "number":
|
||||
case "boolean":
|
||||
return {
|
||||
...baseParameter,
|
||||
type: schema.type,
|
||||
};
|
||||
case "object":
|
||||
if (schema.properties) {
|
||||
const attributes: Parameter[] = [];
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(
|
||||
schema.properties,
|
||||
)) {
|
||||
attributes.push(
|
||||
convertJsonSchemaToParameter(
|
||||
propName,
|
||||
propSchema,
|
||||
requiredFields.includes(propName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseParameter,
|
||||
type: "object",
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseParameter,
|
||||
type: "object",
|
||||
};
|
||||
case "array":
|
||||
if (schema.items.type === "object" && "properties" in schema.items) {
|
||||
const attributes: Parameter[] = [];
|
||||
const requiredFields = schema.items.required || [];
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(
|
||||
schema.items.properties || {},
|
||||
)) {
|
||||
attributes.push(
|
||||
convertJsonSchemaToParameter(
|
||||
propName,
|
||||
propSchema,
|
||||
requiredFields.includes(propName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseParameter,
|
||||
type: "object[]",
|
||||
attributes,
|
||||
};
|
||||
} else if (schema.items.type === "array") {
|
||||
throw new Error("Nested arrays are not supported");
|
||||
} else {
|
||||
return {
|
||||
...baseParameter,
|
||||
type: `${schema.items.type}[]`,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
...baseParameter,
|
||||
type: "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function convertAttribute(attribute: Parameter): JSONSchema {
|
||||
switch (attribute.type) {
|
||||
case "string":
|
||||
return {
|
||||
type: "string",
|
||||
description: attribute.description,
|
||||
...(attribute.enum && { enum: attribute.enum }),
|
||||
};
|
||||
case "number":
|
||||
case "boolean":
|
||||
return {
|
||||
type: attribute.type,
|
||||
description: attribute.description,
|
||||
};
|
||||
case "object":
|
||||
case "object[]":
|
||||
const properties = attribute.attributes?.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.name] = convertAttribute(attr);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
const required = attribute.attributes
|
||||
?.filter((attr) => attr.required !== false)
|
||||
.map((attr) => attr.name);
|
||||
if (attribute.type === "object[]") {
|
||||
return {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
...(properties && { properties }),
|
||||
...(required && required.length > 0 && { required }),
|
||||
},
|
||||
description: attribute.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "object",
|
||||
description: attribute.description,
|
||||
...(properties && { properties }),
|
||||
...(required && required.length > 0 && { required }),
|
||||
};
|
||||
default:
|
||||
// Handle arrays of primitive types and undefined attribute.type
|
||||
if (attribute.type?.endsWith("[]")) {
|
||||
const itemType = attribute.type.slice(0, -2);
|
||||
return {
|
||||
type: "array",
|
||||
items: { type: itemType as any },
|
||||
description: attribute.description,
|
||||
};
|
||||
}
|
||||
// Fallback for undefined type or any other unexpected type
|
||||
return {
|
||||
type: "string",
|
||||
description: attribute.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function convertJsonSchemaToZodSchema(
|
||||
jsonSchema: any,
|
||||
required: boolean,
|
||||
definitions?: Record<string, any>,
|
||||
visitedRefs?: Set<string>,
|
||||
): z.ZodSchema {
|
||||
// Resolve $ref references
|
||||
if (jsonSchema.$ref && definitions) {
|
||||
const refPath = jsonSchema.$ref.replace(
|
||||
/^#\/\$defs\/|^#\/definitions\//,
|
||||
"",
|
||||
);
|
||||
|
||||
// Detect circular $ref cycles
|
||||
const refs = visitedRefs ?? new Set<string>();
|
||||
if (refs.has(refPath)) {
|
||||
console.warn(
|
||||
`[CopilotKit] Circular $ref detected for "${refPath}" — falling back to z.any()`,
|
||||
);
|
||||
let schema = z.any();
|
||||
if (jsonSchema.description) {
|
||||
schema = schema.describe(jsonSchema.description);
|
||||
}
|
||||
return required ? schema : schema.optional();
|
||||
}
|
||||
|
||||
const resolved = definitions[refPath];
|
||||
if (resolved) {
|
||||
// Clone the set so sibling branches don't see each other's visited refs
|
||||
const nextRefs = new Set(refs);
|
||||
nextRefs.add(refPath);
|
||||
return convertJsonSchemaToZodSchema(
|
||||
resolved,
|
||||
required,
|
||||
definitions,
|
||||
nextRefs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect top-level definitions for $ref resolution
|
||||
const defs = definitions ?? jsonSchema.$defs ?? jsonSchema.definitions;
|
||||
|
||||
// Handle null-union types like ["string", "null"]
|
||||
if (Array.isArray(jsonSchema.type)) {
|
||||
const types = jsonSchema.type as string[];
|
||||
const hasNull = types.includes("null");
|
||||
const nonNullTypes = types.filter((t: string) => t !== "null");
|
||||
const resolvedType = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
|
||||
const innerSchema = convertJsonSchemaToZodSchema(
|
||||
{ ...jsonSchema, type: resolvedType },
|
||||
true,
|
||||
defs,
|
||||
visitedRefs,
|
||||
);
|
||||
let schema = hasNull ? z.union([innerSchema, z.null()]) : innerSchema;
|
||||
if (jsonSchema.description) {
|
||||
schema = schema.describe(jsonSchema.description);
|
||||
}
|
||||
return required ? schema : schema.optional();
|
||||
}
|
||||
|
||||
// Handle anyOf / oneOf as z.union
|
||||
const unionVariants = jsonSchema.anyOf ?? jsonSchema.oneOf;
|
||||
if (Array.isArray(unionVariants) && unionVariants.length > 0) {
|
||||
if (unionVariants.length === 1) {
|
||||
return convertJsonSchemaToZodSchema(
|
||||
unionVariants[0],
|
||||
required,
|
||||
defs,
|
||||
visitedRefs,
|
||||
);
|
||||
}
|
||||
const schemas = unionVariants.map((v: any) =>
|
||||
convertJsonSchemaToZodSchema(v, true, defs, visitedRefs),
|
||||
);
|
||||
let schema = z.union(
|
||||
schemas as [z.ZodSchema, z.ZodSchema, ...z.ZodSchema[]],
|
||||
);
|
||||
if (jsonSchema.description) {
|
||||
schema = schema.describe(jsonSchema.description);
|
||||
}
|
||||
return required ? schema : schema.optional();
|
||||
}
|
||||
|
||||
if (jsonSchema.type === "object") {
|
||||
const spec: { [key: string]: z.ZodSchema } = {};
|
||||
|
||||
if (!jsonSchema.properties || !Object.keys(jsonSchema.properties).length) {
|
||||
return !required ? z.object(spec).optional() : z.object(spec);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(jsonSchema.properties)) {
|
||||
spec[key] = convertJsonSchemaToZodSchema(
|
||||
value,
|
||||
jsonSchema.required ? jsonSchema.required.includes(key) : false,
|
||||
defs,
|
||||
visitedRefs,
|
||||
);
|
||||
}
|
||||
let schema = z.object(spec).describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
} else if (jsonSchema.type === "string") {
|
||||
if (jsonSchema.enum && jsonSchema.enum.length > 0) {
|
||||
let schema = z
|
||||
.enum(jsonSchema.enum as [string, ...string[]])
|
||||
.describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
}
|
||||
let schema = z.string().describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
} else if (jsonSchema.type === "number" || jsonSchema.type === "integer") {
|
||||
let schema = z.number().describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
} else if (jsonSchema.type === "boolean") {
|
||||
let schema = z.boolean().describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
} else if (jsonSchema.type === "array") {
|
||||
let itemSchema = convertJsonSchemaToZodSchema(
|
||||
jsonSchema.items,
|
||||
true,
|
||||
defs,
|
||||
visitedRefs,
|
||||
);
|
||||
let schema = z.array(itemSchema).describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
} else if (jsonSchema.type === "null") {
|
||||
let schema = z.null().describe(jsonSchema.description);
|
||||
return required ? schema : schema.optional();
|
||||
}
|
||||
|
||||
// Fallback: accept any value rather than throwing
|
||||
console.warn(
|
||||
`[CopilotKit] Unsupported JSON schema type "${jsonSchema.type ?? "unknown"}" — falling back to z.any()`,
|
||||
);
|
||||
let schema = z.any();
|
||||
if (jsonSchema.description) {
|
||||
schema = schema.describe(jsonSchema.description);
|
||||
}
|
||||
return required ? schema : schema.optional();
|
||||
}
|
||||
|
||||
export function getZodParameters<T extends [] | Parameter[] | undefined>(
|
||||
parameters: T,
|
||||
): any {
|
||||
if (!parameters) return z.object({});
|
||||
const jsonParams = actionParametersToJsonSchema(parameters);
|
||||
return convertJsonSchemaToZodSchema(jsonParams, true);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { v4 as uuidv4, validate, v5 as uuidv5 } from "uuid";
|
||||
|
||||
export function randomId() {
|
||||
return "ck-" + uuidv4();
|
||||
}
|
||||
|
||||
export function randomUUID() {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively converts an object to a serializable form by converting functions to their string representation.
|
||||
*/
|
||||
function toSerializable(value: unknown): unknown {
|
||||
if (typeof value === "function") {
|
||||
return value.toString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(toSerializable);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
result[key] = toSerializable((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function dataToUUID(input: string | object, namespace?: string): string {
|
||||
const BASE_NAMESPACE = "e4b01160-ff74-4c6e-9b27-d53cd930fe8e";
|
||||
// Since namespace needs to be a uuid, we are creating a uuid for it.
|
||||
const boundNamespace = namespace
|
||||
? uuidv5(namespace, BASE_NAMESPACE)
|
||||
: BASE_NAMESPACE;
|
||||
|
||||
const stringInput =
|
||||
typeof input === "string" ? input : JSON.stringify(toSerializable(input));
|
||||
return uuidv5(stringInput, boundNamespace);
|
||||
}
|
||||
|
||||
export function isValidUUID(uuid: string) {
|
||||
return validate(uuid);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Safely read a Response/Request body with sensible defaults:
|
||||
* - clones the response/request to avoid consuming the original response/request
|
||||
* - Skips GET/HEAD
|
||||
* - Tries JSON first regardless of content-type
|
||||
* - Falls back to text and optionally parses when it "looks" like JSON
|
||||
*/
|
||||
export async function readBody<T extends Response | Request>(
|
||||
r: T,
|
||||
): Promise<unknown> {
|
||||
// skip GET/HEAD requests (unchanged)
|
||||
const method = "method" in r ? r.method.toUpperCase() : undefined;
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// no body at all → undefined (unchanged)
|
||||
if (!("body" in r) || r.body == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 1) try JSON (unchanged)
|
||||
try {
|
||||
return await r.clone().json();
|
||||
} catch {
|
||||
// 2) try text (unchanged + your whitespace/JSON-heuristic)
|
||||
try {
|
||||
const text = await r.clone().text();
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (trimmed.length === 0) return text;
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
// 3) FINAL FALLBACK: manual read that accepts string or bytes
|
||||
try {
|
||||
const c = r.clone();
|
||||
const stream: ReadableStream | null = c.body ?? null;
|
||||
if (!stream) return undefined;
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (typeof value === "string") {
|
||||
out += value; // accept string chunks
|
||||
} else {
|
||||
out += decoder.decode(value, { stream: true }); // bytes
|
||||
}
|
||||
}
|
||||
out += decoder.decode(); // flush
|
||||
|
||||
const trimmed = out.trim();
|
||||
if (trimmed.length === 0) return out;
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return undefined; // same "give up" behavior you had
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AgentCapabilities } from "@ag-ui/core";
|
||||
|
||||
export type MaybePromise<T> = T | PromiseLike<T>;
|
||||
|
||||
/**
|
||||
* More specific utility for records with at least one key
|
||||
*/
|
||||
export type NonEmptyRecord<T> =
|
||||
T extends Record<string, unknown>
|
||||
? keyof T extends never
|
||||
? never
|
||||
: T
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Type representing an agent's basic information
|
||||
*/
|
||||
export interface AgentDescription {
|
||||
name: string;
|
||||
className: string;
|
||||
description: string;
|
||||
capabilities?: AgentCapabilities;
|
||||
}
|
||||
|
||||
export type RuntimeMode = "sse" | "intelligence";
|
||||
|
||||
export const RUNTIME_MODE_SSE = "sse" as const;
|
||||
export const RUNTIME_MODE_INTELLIGENCE = "intelligence" as const;
|
||||
|
||||
export interface IntelligenceRuntimeInfo {
|
||||
wsUrl: string;
|
||||
}
|
||||
|
||||
export interface ThreadEndpointRuntimeInfo {
|
||||
list: boolean;
|
||||
inspect: boolean;
|
||||
mutations: boolean;
|
||||
realtimeMetadata: boolean;
|
||||
}
|
||||
|
||||
export type RuntimeLicenseStatus =
|
||||
| "valid"
|
||||
| "none"
|
||||
| "expired"
|
||||
| "expiring"
|
||||
| "invalid"
|
||||
| "unknown";
|
||||
|
||||
export interface A2UIRuntimeInfo {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Agent ids the runtime applies A2UI to. When omitted, A2UI applies to
|
||||
* every agent served by the runtime.
|
||||
*/
|
||||
agents?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeInfo {
|
||||
version: string;
|
||||
agents: Record<string, AgentDescription>;
|
||||
audioFileTranscriptionEnabled: boolean;
|
||||
mode: RuntimeMode;
|
||||
intelligence?: IntelligenceRuntimeInfo;
|
||||
threadEndpoints?: ThreadEndpointRuntimeInfo;
|
||||
/**
|
||||
* When true, the runtime exposes POST /agent/:agentId/suggest for stateless
|
||||
* suggestion generation. Absent on older runtimes; clients fall back to a
|
||||
* client-side agent run.
|
||||
*/
|
||||
suggestions?: boolean;
|
||||
/**
|
||||
* @deprecated Use `a2ui` instead, which preserves per-agent scoping.
|
||||
* Kept for backward compatibility with older clients.
|
||||
*/
|
||||
a2uiEnabled?: boolean;
|
||||
a2ui?: A2UIRuntimeInfo;
|
||||
openGenerativeUIEnabled?: boolean;
|
||||
licenseStatus?: RuntimeLicenseStatus;
|
||||
telemetryDisabled?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user