chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+116
View File
@@ -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"),
);
});
});
});
+115
View File
@@ -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);
});
});
+19
View File
@@ -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";
+67
View File
@@ -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>;
}
+164
View File
@@ -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";
}
+10
View 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;
}
+55
View File
@@ -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 };
}
+154
View File
@@ -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;
}
+89
View File
@@ -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";
+1
View File
@@ -0,0 +1 @@
export const logger = console;
+82
View File
@@ -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.`,
);
}
+35
View File
@@ -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;
}
+7
View File
@@ -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.
}
}
+43
View File
@@ -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);
});
});
+133
View File
@@ -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[];
};
};
};
}
+79
View File
@@ -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>;
+6
View File
@@ -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";
+65
View File
@@ -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>;
+2
View File
@@ -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] };
+66
View File
@@ -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);
});
});
+23
View File
@@ -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;
}
}
+116
View File
@@ -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;
+558
View File
@@ -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;
}
};
+108
View File
@@ -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 {};
}
}
+402
View File
@@ -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);
}
+45
View File
@@ -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);
}
+79
View File
@@ -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
}
}
}
}
+80
View File
@@ -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;
}