llm-native-recorded.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
  2. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  3. import { ModelsDev } from "@kirincode-ai/core/models-dev"
  4. import { HttpRecorder } from "@kirincode-ai/http-recorder"
  5. import { HttpRecorderInternal } from "@kirincode-ai/http-recorder/internal"
  6. import { describe, expect, test } from "bun:test"
  7. import { tool, type ModelMessage, type JSONValue } from "ai"
  8. import { Effect, Layer, Option, Schema, Stream } from "effect"
  9. import path from "node:path"
  10. import z from "zod"
  11. import { Auth } from "@/auth"
  12. import { Provider } from "@/provider/provider"
  13. import { Filesystem } from "@/util/filesystem"
  14. import { LLMEvent, LLMResponse } from "@kirincode-ai/llm"
  15. import { RequestExecutor } from "@kirincode-ai/llm/route"
  16. import { RuntimeFlags } from "@/effect/runtime-flags"
  17. import type { Agent } from "../../src/agent/agent"
  18. import { LLM } from "../../src/session/llm"
  19. import { MessageID, SessionID } from "../../src/session/schema"
  20. import { TestInstance } from "../fixture/fixture"
  21. import { testEffect } from "../lib/effect"
  22. import { ProviderV2 } from "@kirincode-ai/core/provider"
  23. import { ModelV2 } from "@kirincode-ai/core/model"
  24. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  25. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  26. import { LayerNodePlatform } from "@kirincode-ai/core/effect/app-node-platform"
  27. const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
  28. const zenURL = (connection: string) => `https://console.kirincode.ai/proxy/connections/${connection}/v1`
  29. const replayOpenAIOAuth = {
  30. type: "oauth",
  31. refresh: "fixture-refresh-token",
  32. access: "fixture-access-token",
  33. expires: Date.now() + 60 * 60 * 1000,
  34. accountId: "fixture-account",
  35. } satisfies Auth.Info
  36. type RecordedScenario = {
  37. readonly id: string
  38. readonly name: string
  39. readonly providerID: ProviderV2.ID
  40. readonly modelID: string
  41. readonly cassette: string
  42. readonly protocol: string
  43. readonly tags: ReadonlyArray<string>
  44. readonly canRecord: () => boolean
  45. readonly recordAuth?: () => Auth.Info | undefined
  46. readonly replayAuth?: Auth.Info
  47. readonly stableID?: string
  48. readonly config: (model: ModelsDev.Provider["models"][string]) => Partial<ConfigV1.Info>
  49. }
  50. const cloneModel = (model: ModelsDev.Provider["models"][string]) => {
  51. const cloned = structuredClone(model)
  52. const { experimental, ...rest } = cloned
  53. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The config schema accepts the same model shape except object-valued experimental metadata.
  54. if (typeof experimental === "boolean") {
  55. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The fixture model already matches config input when experimental is boolean.
  56. return cloned as NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
  57. }
  58. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Dropping non-boolean experimental metadata makes the fixture model match config input.
  59. return rest as NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
  60. }
  61. const envValue = (...names: string[]) => names.map((name) => process.env[name]).find(Boolean)
  62. const decodeAuth = Schema.decodeUnknownOption(Auth.Info)
  63. const recordOpenAIOAuth = (() => {
  64. let loaded = false
  65. let auth: Auth.Info | undefined
  66. return () => {
  67. if (loaded) return auth
  68. loaded = true
  69. auth = decodeRecordOpenAIOAuth()
  70. return auth
  71. }
  72. })()
  73. function decodeRecordOpenAIOAuth() {
  74. const value = process.env.KIRINCODE_RECORD_OPENAI_AUTH
  75. if (!value) return undefined
  76. try {
  77. const auth = Option.getOrUndefined(decodeAuth(JSON.parse(value)))
  78. return auth?.type === "oauth" ? auth : undefined
  79. } catch {
  80. return undefined
  81. }
  82. }
  83. const providerConfig = (input: {
  84. readonly providerID: ProviderV2.ID
  85. readonly name: string
  86. readonly env: string[]
  87. readonly npm: string
  88. readonly api: string
  89. readonly model: ModelsDev.Provider["models"][string]
  90. readonly options: Record<string, unknown>
  91. }): Partial<ConfigV1.Info> => ({
  92. enabled_providers: [input.providerID],
  93. provider: {
  94. [input.providerID]: {
  95. name: input.name,
  96. env: input.env,
  97. npm: input.npm,
  98. api: input.api,
  99. models: { [input.model.id]: cloneModel(input.model) },
  100. options: input.options,
  101. },
  102. },
  103. })
  104. const RECORDED_SCENARIOS = [
  105. {
  106. id: "openai-api-key",
  107. name: "OpenAI API key",
  108. providerID: ProviderV2.ID.openai,
  109. modelID: "gpt-4.1-mini",
  110. cassette: "session/native-openai-tool-loop",
  111. protocol: "openai-responses",
  112. tags: ["kirincode", "native", "tool-loop"],
  113. canRecord: () => Boolean(envValue("KIRINCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY")),
  114. config: (model) =>
  115. providerConfig({
  116. providerID: ProviderV2.ID.openai,
  117. name: "OpenAI",
  118. env: ["OPENAI_API_KEY"],
  119. npm: "@ai-sdk/openai",
  120. api: "https://api.openai.com/v1",
  121. model,
  122. options: {
  123. apiKey: envValue("KIRINCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY") ?? "fixture-openai-key",
  124. baseURL: "https://api.openai.com/v1",
  125. },
  126. }),
  127. },
  128. {
  129. id: "openai-oauth",
  130. name: "OpenAI OAuth",
  131. providerID: ProviderV2.ID.openai,
  132. modelID: "gpt-5.5",
  133. cassette: "session/native-openai-oauth-tool-loop",
  134. protocol: "openai-responses",
  135. tags: ["kirincode", "native", "oauth", "tool-loop"],
  136. canRecord: () => recordOpenAIOAuth() !== undefined,
  137. recordAuth: recordOpenAIOAuth,
  138. replayAuth: replayOpenAIOAuth,
  139. stableID: "openai-oauth",
  140. config: (model) =>
  141. providerConfig({
  142. providerID: ProviderV2.ID.openai,
  143. name: "OpenAI",
  144. env: ["OPENAI_API_KEY"],
  145. npm: "@ai-sdk/openai",
  146. api: "https://api.openai.com/v1",
  147. model,
  148. options: { baseURL: "https://api.openai.com/v1" },
  149. }),
  150. },
  151. {
  152. id: "opencode-proxy",
  153. name: "KirinCode proxy",
  154. providerID: ProviderV2.ID.kirincode,
  155. modelID: "gpt-5.2-codex",
  156. cassette: "session/native-zen-tool-loop",
  157. protocol: "openai-responses",
  158. tags: ["kirincode", "zen", "native", "tool-loop"],
  159. canRecord: () => Boolean(process.env.KIRINCODE_RECORD_CONSOLE_TOKEN && process.env.KIRINCODE_RECORD_ZEN_ORG_ID),
  160. config: (model) =>
  161. providerConfig({
  162. providerID: ProviderV2.ID.kirincode,
  163. name: "KirinCode Zen",
  164. env: ["KIRINCODE_CONSOLE_TOKEN"],
  165. npm: "@ai-sdk/openai-compatible",
  166. api: zenURL(process.env.KIRINCODE_RECORD_ZEN_CONNECTION ?? "fixture"),
  167. model,
  168. options: {
  169. apiKey: process.env.KIRINCODE_RECORD_CONSOLE_TOKEN ?? "fixture-console-token",
  170. headers: { "x-org-id": process.env.KIRINCODE_RECORD_ZEN_ORG_ID ?? "fixture-org" },
  171. },
  172. }),
  173. },
  174. {
  175. id: "anthropic-api-key",
  176. name: "Anthropic API key",
  177. providerID: ProviderV2.ID.anthropic,
  178. modelID: "claude-haiku-4-5-20251001",
  179. cassette: "session/native-anthropic-tool-loop",
  180. protocol: "anthropic-messages",
  181. tags: ["kirincode", "native", "tool-loop"],
  182. canRecord: () => Boolean(envValue("KIRINCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")),
  183. config: (model) =>
  184. providerConfig({
  185. providerID: ProviderV2.ID.anthropic,
  186. name: "Anthropic",
  187. env: ["ANTHROPIC_API_KEY"],
  188. npm: "@ai-sdk/anthropic",
  189. api: "https://api.anthropic.com/v1",
  190. model,
  191. options: {
  192. apiKey: envValue("KIRINCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY") ?? "fixture-anthropic-key",
  193. baseURL: "https://api.anthropic.com/v1",
  194. },
  195. }),
  196. },
  197. ] satisfies ReadonlyArray<RecordedScenario>
  198. const shouldRecord = process.env.RECORD === "true"
  199. const selectedScenarios = new Set(
  200. (envValue("KIRINCODE_RECORDED_SCENARIO", "RECORDED_PROVIDER") ?? "")
  201. .split(",")
  202. .map((item) => item.trim().toLowerCase())
  203. .filter(Boolean),
  204. )
  205. function isSelected(scenario: RecordedScenario) {
  206. if (selectedScenarios.size === 0) return true
  207. return [scenario.id, scenario.name, scenario.providerID, scenario.cassette, ...scenario.tags]
  208. .map((item) => item.toLowerCase())
  209. .some((item) => selectedScenarios.has(item))
  210. }
  211. const canRun = (scenario: RecordedScenario) =>
  212. shouldRecord
  213. ? scenario.canRecord()
  214. : HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR })
  215. const recordError = (scenario: RecordedScenario) =>
  216. scenario.id === "openai-oauth"
  217. ? "Set KIRINCODE_RECORD_OPENAI_AUTH to an OAuth auth JSON object in the recording environment."
  218. : `Missing recording credentials for ${scenario.name}.`
  219. const redactRecordedBody = (body: string) =>
  220. body
  221. .replace(/wrk_[A-Z0-9]+/g, "wrk_redacted")
  222. .replace(/"safety_identifier"\s*:\s*"user-[^"]+"/g, '"safety_identifier":"user_redacted"')
  223. .replace(/"(access|access_token|refresh|refresh_token|accountId|account_id)"\s*:\s*"[^"]+"/g, '"$1":"redacted"')
  224. function authLayer(scenario: RecordedScenario) {
  225. const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth
  226. if (!replayAuth) return undefined
  227. return Layer.mock(Auth.Service)({
  228. get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined),
  229. all: () => Effect.succeed({ [scenario.providerID]: replayAuth }),
  230. })
  231. }
  232. async function loadFixture(providerID: string, modelID: string) {
  233. const data = await modelsFixture
  234. const provider = data[providerID]
  235. if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
  236. const model = provider.models[modelID]
  237. if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
  238. return model
  239. }
  240. const modelsFixture = Filesystem.readJson<Record<string, ModelsDev.Provider>>(
  241. path.join(import.meta.dir, "../tool/fixtures/models-api.json"),
  242. )
  243. function recordedNativeLLMLayer(scenario: RecordedScenario) {
  244. const auth = authLayer(scenario)
  245. // Only the HTTP client is recorded; RequestExecutor and the kirincode LLM stack remain real.
  246. const metadata = {
  247. provider: scenario.providerID,
  248. protocol: scenario.protocol,
  249. route: scenario.protocol,
  250. tags: scenario.tags,
  251. }
  252. const redact = {
  253. url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"),
  254. body: redactRecordedBody,
  255. }
  256. const recordedHttp = shouldRecord
  257. ? HttpRecorderInternal.cassetteLayer(scenario.cassette, {
  258. directory: FIXTURES_DIR,
  259. mode: "record",
  260. metadata,
  261. redactor: HttpRecorderInternal.Redactor.make(redact),
  262. })
  263. : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact })
  264. return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [
  265. [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))],
  266. [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })],
  267. ...(auth ? ([[Auth.node, auth]] as const) : []),
  268. ])
  269. }
  270. const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) =>
  271. Effect.promise(() =>
  272. Bun.write(
  273. path.join(directory, "kirincode.json"),
  274. JSON.stringify({ $schema: "https://kirincode.ai/config.json", ...scenario.config(model) }),
  275. ),
  276. )
  277. const collect = (input: LLM.StreamInput) =>
  278. Effect.gen(function* () {
  279. const llm = yield* LLM.Service
  280. return Array.from(yield* llm.stream(input).pipe(Stream.runCollect))
  281. })
  282. const WEATHER_RESULT = { temperature: 22, condition: "sunny" } as const
  283. const WEATHER_SYSTEM =
  284. "Use the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny."
  285. const WEATHER_USER = "What is the weather in Paris?"
  286. const weatherTool = tool({
  287. description: "Get the current weather for a city.",
  288. inputSchema: z.object({ city: z.string() }),
  289. execute: async () => WEATHER_RESULT,
  290. })
  291. const toolRoundtrip = (
  292. events: ReadonlyArray<LLMEvent>,
  293. call: { readonly id: string; readonly name: string; readonly input: unknown },
  294. result: JSONValue,
  295. ): ModelMessage[] => [
  296. {
  297. role: "assistant",
  298. content: [
  299. ...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
  300. type: "reasoning" as const,
  301. text: events
  302. .filter(LLMEvent.is.reasoningDelta)
  303. .filter((event) => event.id === part.id)
  304. .map((event) => event.text)
  305. .join(""),
  306. providerMetadata: part.providerMetadata,
  307. })),
  308. { type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
  309. ],
  310. },
  311. {
  312. role: "tool",
  313. content: [
  314. { type: "tool-result", toolCallId: call.id, toolName: call.name, output: { type: "json", value: result } },
  315. ],
  316. },
  317. ]
  318. const driveToolLoop = (scenario: RecordedScenario) =>
  319. Effect.gen(function* () {
  320. const test = yield* TestInstance
  321. const model = yield* Effect.promise(() => loadFixture(scenario.providerID, scenario.modelID))
  322. yield* writeConfig(test.directory, scenario, model)
  323. const stableID = scenario.stableID ?? scenario.providerID
  324. const sessionID = SessionID.make(`session-recorded-${stableID}-loop`)
  325. const modelID = ModelV2.ID.make(model.id)
  326. const agent = {
  327. name: "test",
  328. mode: "primary",
  329. prompt: "Answer using tools when appropriate.",
  330. options: {},
  331. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  332. temperature: 0,
  333. } satisfies Agent.Info
  334. const provider = yield* Provider.Service
  335. const resolved = yield* provider.getModel(scenario.providerID, modelID)
  336. const userMessage = { role: "user", content: WEATHER_USER } satisfies ModelMessage
  337. const base = {
  338. user: {
  339. id: MessageID.make(`msg_user-recorded-${stableID}-loop`),
  340. sessionID,
  341. role: "user",
  342. time: { created: 0 },
  343. agent: agent.name,
  344. model: { providerID: scenario.providerID, modelID },
  345. } satisfies SessionV1.User,
  346. sessionID,
  347. model: resolved,
  348. agent,
  349. system: [WEATHER_SYSTEM],
  350. tools: { get_weather: weatherTool },
  351. }
  352. const turn1 = yield* collect({ ...base, messages: [userMessage] })
  353. const toolCall = turn1.find(LLMEvent.is.toolCall)
  354. expect(toolCall).toBeDefined()
  355. expect(turn1.find(LLMEvent.is.toolResult)).toBeDefined()
  356. expect(toolCall!.name).toBe("get_weather")
  357. expect(toolCall!.input).toMatchObject({ city: expect.stringMatching(/Paris/i) })
  358. expect(turn1.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
  359. const turn2 = yield* collect({
  360. ...base,
  361. messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
  362. })
  363. expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
  364. expect(turn2.filter(LLMEvent.is.finish)).toHaveLength(1)
  365. expect(turn2.filter(LLMEvent.is.toolCall)).toHaveLength(0)
  366. })
  367. describe("session.llm native recorded", () => {
  368. for (const scenario of RECORDED_SCENARIOS.filter(isSelected)) {
  369. if (!canRun(scenario)) {
  370. if (shouldRecord && scenario.recordAuth && selectedScenarios.size > 0) {
  371. test(`${scenario.name}: drives a tool loop to a final text answer`, () => {
  372. throw new Error(recordError(scenario))
  373. })
  374. continue
  375. }
  376. test.skip(`${scenario.name}: drives a tool loop to a final text answer`, () => {})
  377. continue
  378. }
  379. const it = testEffect(recordedNativeLLMLayer(scenario))
  380. it.instance(`${scenario.name}: drives a tool loop to a final text answer`, () => driveToolLoop(scenario))
  381. }
  382. })