Skip to content

Commit 9aa13e9

Browse files
bchapuisclaude
andcommitted
Add Kimi K2.5 text node
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 28b2358 commit 9aa13e9

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

apps/api/src/runtime/cloudflare-node-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ import { DistilbertSst2Int8Node } from "@dafthunk/runtime/nodes/text/distilbert-
407407
import { Glm47FlashNode } from "@dafthunk/runtime/nodes/text/glm-4-7-flash-node";
408408
import { Hermes2ProMistral7BNode } from "@dafthunk/runtime/nodes/text/hermes-2-pro-mistral-7b-node";
409409
import { JsonStringTemplateNode } from "@dafthunk/runtime/nodes/text/json-string-template-node";
410+
import { KimiK25Node } from "@dafthunk/runtime/nodes/text/kimi-k2-5-node";
410411
import { Llama318BInstructFastNode } from "@dafthunk/runtime/nodes/text/llama-3-1-8b-instruct-fast-node";
411412
import { Llama3370BInstructFastNode } from "@dafthunk/runtime/nodes/text/llama-3-3-70b-instruct-fp8-fast-node";
412413
import { Llama4Scout17B16EInstructNode } from "@dafthunk/runtime/nodes/text/llama-4-scout-17b-16e-instruct-node";
@@ -548,6 +549,7 @@ export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
548549
this.registerImplementation(Glm47FlashNode);
549550
this.registerImplementation(Qwq32BNode);
550551
this.registerImplementation(Qwen330BA3BFp8Node);
552+
this.registerImplementation(KimiK25Node);
551553
this.registerImplementation(WhisperNode);
552554
this.registerImplementation(WhisperLargeV3TurboNode);
553555
this.registerImplementation(WhisperTinyEnNode);
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import type { NodeContext } from "@dafthunk/runtime";
2+
import type { Node } from "@dafthunk/types";
3+
import { describe, expect, it } from "vitest";
4+
import { KimiK25Node } from "./kimi-k2-5-node";
5+
6+
describe("KimiK25Node", () => {
7+
const createContext = (
8+
inputs: Record<string, unknown>,
9+
aiResponse?: Record<string, unknown>
10+
): NodeContext => {
11+
const mockAI = {
12+
run: async () =>
13+
aiResponse ?? {
14+
choices: [
15+
{
16+
message: {
17+
content: "Hello! How can I help you?",
18+
reasoning_content: "The user greeted me.",
19+
},
20+
},
21+
],
22+
usage: {
23+
prompt_tokens: 10,
24+
completion_tokens: 20,
25+
},
26+
},
27+
};
28+
29+
return {
30+
nodeId: "kimi-k2-5",
31+
inputs,
32+
getIntegration: async () => {
33+
throw new Error("No integrations in test");
34+
},
35+
env: {
36+
AI: mockAI,
37+
AI_OPTIONS: {},
38+
},
39+
} as unknown as NodeContext;
40+
};
41+
42+
it("should have correct node type metadata", () => {
43+
expect(KimiK25Node.nodeType.id).toBe("kimi-k2-5");
44+
expect(KimiK25Node.nodeType.name).toBe("Kimi K2.5");
45+
expect(KimiK25Node.nodeType.icon).toBe("sparkles");
46+
expect(KimiK25Node.nodeType.functionCalling).toBe(true);
47+
expect(KimiK25Node.nodeType.usage).toBe(1);
48+
});
49+
50+
it("should generate text from a prompt", async () => {
51+
const node = new KimiK25Node({
52+
nodeId: "kimi-k2-5",
53+
} as unknown as Node);
54+
const result = await node.execute(createContext({ prompt: "Hello" }));
55+
56+
expect(result.status).toBe("completed");
57+
expect(result.outputs?.response).toBe("Hello! How can I help you?");
58+
expect(result.outputs?.reasoning).toBe("The user greeted me.");
59+
});
60+
61+
it("should generate text from messages JSON", async () => {
62+
const messages = JSON.stringify([
63+
{ role: "user", content: "What is 2+2?" },
64+
]);
65+
const node = new KimiK25Node({
66+
nodeId: "kimi-k2-5",
67+
} as unknown as Node);
68+
const result = await node.execute(
69+
createContext({ prompt: "fallback", messages })
70+
);
71+
72+
expect(result.status).toBe("completed");
73+
expect(result.outputs?.response).toBe("Hello! How can I help you?");
74+
});
75+
76+
it("should fall back to prompt when messages JSON is invalid", async () => {
77+
const node = new KimiK25Node({
78+
nodeId: "kimi-k2-5",
79+
} as unknown as Node);
80+
const result = await node.execute(
81+
createContext({ prompt: "Hello", messages: "not-valid-json" })
82+
);
83+
84+
expect(result.status).toBe("completed");
85+
expect(result.outputs?.response).toBe("Hello! How can I help you?");
86+
});
87+
88+
it("should return error when AI service is not available", async () => {
89+
const node = new KimiK25Node({
90+
nodeId: "kimi-k2-5",
91+
} as unknown as Node);
92+
const context = {
93+
nodeId: "kimi-k2-5",
94+
inputs: { prompt: "Hello" },
95+
getIntegration: async () => {
96+
throw new Error("No integrations in test");
97+
},
98+
env: {},
99+
} as unknown as NodeContext;
100+
101+
const result = await node.execute(context);
102+
expect(result.status).toBe("error");
103+
expect(result.error).toContain("AI service is not available");
104+
});
105+
106+
it("should return error when neither prompt nor messages provided", async () => {
107+
const node = new KimiK25Node({
108+
nodeId: "kimi-k2-5",
109+
} as unknown as Node);
110+
const result = await node.execute(createContext({}));
111+
112+
expect(result.status).toBe("error");
113+
expect(result.error).toContain(
114+
"Either prompt or messages must be provided"
115+
);
116+
});
117+
118+
it("should handle response without reasoning content", async () => {
119+
const node = new KimiK25Node({
120+
nodeId: "kimi-k2-5",
121+
} as unknown as Node);
122+
const result = await node.execute(
123+
createContext(
124+
{ prompt: "Hello" },
125+
{
126+
choices: [
127+
{
128+
message: {
129+
content: "Hi there!",
130+
},
131+
},
132+
],
133+
usage: {
134+
prompt_tokens: 5,
135+
completion_tokens: 10,
136+
},
137+
}
138+
)
139+
);
140+
141+
expect(result.status).toBe("completed");
142+
expect(result.outputs?.response).toBe("Hi there!");
143+
expect(result.outputs?.reasoning).toBeUndefined();
144+
});
145+
146+
it("should handle empty response from model", async () => {
147+
const node = new KimiK25Node({
148+
nodeId: "kimi-k2-5",
149+
} as unknown as Node);
150+
const result = await node.execute(
151+
createContext(
152+
{ prompt: "Hello" },
153+
{
154+
choices: [{ message: {} }],
155+
usage: {
156+
prompt_tokens: 5,
157+
completion_tokens: 0,
158+
},
159+
}
160+
)
161+
);
162+
163+
expect(result.status).toBe("completed");
164+
expect(result.outputs?.response).toBe("");
165+
});
166+
167+
it("should handle AI.run throwing an error", async () => {
168+
const node = new KimiK25Node({
169+
nodeId: "kimi-k2-5",
170+
} as unknown as Node);
171+
const context = {
172+
nodeId: "kimi-k2-5",
173+
inputs: { prompt: "Hello" },
174+
getIntegration: async () => {
175+
throw new Error("No integrations in test");
176+
},
177+
env: {
178+
AI: {
179+
run: async () => {
180+
throw new Error("Model inference failed");
181+
},
182+
},
183+
AI_OPTIONS: {},
184+
},
185+
} as unknown as NodeContext;
186+
187+
const result = await node.execute(context);
188+
expect(result.status).toBe("error");
189+
expect(result.error).toContain("Model inference failed");
190+
});
191+
192+
it("should fall back to text estimation when usage is not available", async () => {
193+
const node = new KimiK25Node({
194+
nodeId: "kimi-k2-5",
195+
} as unknown as Node);
196+
const result = await node.execute(
197+
createContext(
198+
{ prompt: "Hello" },
199+
{
200+
choices: [
201+
{
202+
message: {
203+
content: "Response text",
204+
},
205+
},
206+
],
207+
}
208+
)
209+
);
210+
211+
expect(result.status).toBe("completed");
212+
expect(result.outputs?.response).toBe("Response text");
213+
expect(result.usage).toBeGreaterThanOrEqual(1);
214+
});
215+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { ExecutableNode, type NodeContext } from "@dafthunk/runtime";
2+
import type { NodeExecution, NodeType } from "@dafthunk/types";
3+
import type { TokenPricing } from "../../utils/usage";
4+
import { executeWorkersAiTextModel } from "./execute-workers-ai-text-model";
5+
6+
// https://developers.cloudflare.com/workers-ai/platform/pricing/
7+
const PRICING: TokenPricing = {
8+
inputCostPerMillion: 0.6,
9+
outputCostPerMillion: 3.0,
10+
};
11+
12+
/**
13+
* Kimi K2.5 Node implementation with function calling support.
14+
* Returns OpenAI chat-completions format (not standard Workers AI format).
15+
*/
16+
export class KimiK25Node extends ExecutableNode {
17+
public static readonly nodeType: NodeType = {
18+
id: "kimi-k2-5",
19+
name: "Kimi K2.5",
20+
type: "kimi-k2-5",
21+
description:
22+
"Generates text with function calling support using Kimi K2.5 model",
23+
tags: ["AI", "LLM", "Cloudflare", "Kimi", "Moonshot", "Reasoning"],
24+
icon: "sparkles",
25+
documentation:
26+
"This node generates text with function calling support using the Kimi K2.5 model by Moonshot AI. It features strong reasoning capabilities, multilingual support, and tool use.",
27+
referenceUrl:
28+
"https://developers.cloudflare.com/workers-ai/models/kimi-k2.5/",
29+
usage: 1,
30+
functionCalling: true,
31+
inputs: [
32+
{
33+
name: "prompt",
34+
type: "string",
35+
description: "The input text prompt for the LLM",
36+
required: true,
37+
},
38+
{
39+
name: "messages",
40+
type: "string",
41+
description: "JSON string of conversation messages",
42+
required: false,
43+
},
44+
{
45+
name: "tools",
46+
type: "json",
47+
description: "Array of tool references for function calling",
48+
hidden: true,
49+
value: [] as any,
50+
},
51+
{
52+
name: "temperature",
53+
type: "number",
54+
description: "Controls randomness in the output (0.0 to 5.0)",
55+
hidden: true,
56+
value: 0.7,
57+
},
58+
{
59+
name: "max_tokens",
60+
type: "number",
61+
description:
62+
"Maximum number of tokens to generate (includes reasoning tokens)",
63+
hidden: true,
64+
value: 2048,
65+
},
66+
{
67+
name: "top_p",
68+
type: "number",
69+
description: "Controls diversity via nucleus sampling (0.0 to 1.0)",
70+
hidden: true,
71+
value: 1.0,
72+
},
73+
{
74+
name: "seed",
75+
type: "number",
76+
description: "Random seed for deterministic generation",
77+
hidden: true,
78+
},
79+
{
80+
name: "frequency_penalty",
81+
type: "number",
82+
description: "Penalty for frequency of tokens (0.0 to 2.0)",
83+
hidden: true,
84+
value: 0.0,
85+
},
86+
{
87+
name: "presence_penalty",
88+
type: "number",
89+
description: "Penalty for presence of tokens (0.0 to 2.0)",
90+
hidden: true,
91+
value: 0.0,
92+
},
93+
],
94+
outputs: [
95+
{
96+
name: "response",
97+
type: "string",
98+
description: "Generated text response",
99+
},
100+
{
101+
name: "reasoning",
102+
type: "string",
103+
description: "Model's reasoning process",
104+
hidden: true,
105+
},
106+
{
107+
name: "tool_calls",
108+
type: "json",
109+
description: "Function calls made by the model",
110+
hidden: true,
111+
},
112+
],
113+
};
114+
115+
async execute(context: NodeContext): Promise<NodeExecution> {
116+
const {
117+
temperature,
118+
max_tokens,
119+
top_p,
120+
seed,
121+
frequency_penalty,
122+
presence_penalty,
123+
} = context.inputs;
124+
125+
return executeWorkersAiTextModel(this, context, {
126+
modelId: "@cf/moonshotai/kimi-k2.5",
127+
pricing: PRICING,
128+
params: {
129+
temperature,
130+
max_completion_tokens: max_tokens,
131+
top_p,
132+
seed,
133+
frequency_penalty,
134+
presence_penalty,
135+
stream: false,
136+
},
137+
});
138+
}
139+
}

0 commit comments

Comments
 (0)