| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 |
- import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
- import { SessionV1 } from "@kirincode-ai/core/v1/session"
- import { ModelsDev } from "@kirincode-ai/core/models-dev"
- import { HttpRecorder } from "@kirincode-ai/http-recorder"
- import { HttpRecorderInternal } from "@kirincode-ai/http-recorder/internal"
- import { describe, expect, test } from "bun:test"
- import { tool, type ModelMessage, type JSONValue } from "ai"
- import { Effect, Layer, Option, Schema, Stream } from "effect"
- import path from "node:path"
- import z from "zod"
- import { Auth } from "@/auth"
- import { Provider } from "@/provider/provider"
- import { Filesystem } from "@/util/filesystem"
- import { LLMEvent, LLMResponse } from "@kirincode-ai/llm"
- import { RequestExecutor } from "@kirincode-ai/llm/route"
- import { RuntimeFlags } from "@/effect/runtime-flags"
- import type { Agent } from "../../src/agent/agent"
- import { LLM } from "../../src/session/llm"
- import { MessageID, SessionID } from "../../src/session/schema"
- import { TestInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { LayerNodePlatform } from "@kirincode-ai/core/effect/app-node-platform"
- const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
- const zenURL = (connection: string) => `https://console.kirincode.ai/proxy/connections/${connection}/v1`
- const replayOpenAIOAuth = {
- type: "oauth",
- refresh: "fixture-refresh-token",
- access: "fixture-access-token",
- expires: Date.now() + 60 * 60 * 1000,
- accountId: "fixture-account",
- } satisfies Auth.Info
- type RecordedScenario = {
- readonly id: string
- readonly name: string
- readonly providerID: ProviderV2.ID
- readonly modelID: string
- readonly cassette: string
- readonly protocol: string
- readonly tags: ReadonlyArray<string>
- readonly canRecord: () => boolean
- readonly recordAuth?: () => Auth.Info | undefined
- readonly replayAuth?: Auth.Info
- readonly stableID?: string
- readonly config: (model: ModelsDev.Provider["models"][string]) => Partial<ConfigV1.Info>
- }
- const cloneModel = (model: ModelsDev.Provider["models"][string]) => {
- const cloned = structuredClone(model)
- const { experimental, ...rest } = cloned
- // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The config schema accepts the same model shape except object-valued experimental metadata.
- if (typeof experimental === "boolean") {
- // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The fixture model already matches config input when experimental is boolean.
- return cloned as NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
- }
- // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Dropping non-boolean experimental metadata makes the fixture model match config input.
- return rest as NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
- }
- const envValue = (...names: string[]) => names.map((name) => process.env[name]).find(Boolean)
- const decodeAuth = Schema.decodeUnknownOption(Auth.Info)
- const recordOpenAIOAuth = (() => {
- let loaded = false
- let auth: Auth.Info | undefined
- return () => {
- if (loaded) return auth
- loaded = true
- auth = decodeRecordOpenAIOAuth()
- return auth
- }
- })()
- function decodeRecordOpenAIOAuth() {
- const value = process.env.KIRINCODE_RECORD_OPENAI_AUTH
- if (!value) return undefined
- try {
- const auth = Option.getOrUndefined(decodeAuth(JSON.parse(value)))
- return auth?.type === "oauth" ? auth : undefined
- } catch {
- return undefined
- }
- }
- const providerConfig = (input: {
- readonly providerID: ProviderV2.ID
- readonly name: string
- readonly env: string[]
- readonly npm: string
- readonly api: string
- readonly model: ModelsDev.Provider["models"][string]
- readonly options: Record<string, unknown>
- }): Partial<ConfigV1.Info> => ({
- enabled_providers: [input.providerID],
- provider: {
- [input.providerID]: {
- name: input.name,
- env: input.env,
- npm: input.npm,
- api: input.api,
- models: { [input.model.id]: cloneModel(input.model) },
- options: input.options,
- },
- },
- })
- const RECORDED_SCENARIOS = [
- {
- id: "openai-api-key",
- name: "OpenAI API key",
- providerID: ProviderV2.ID.openai,
- modelID: "gpt-4.1-mini",
- cassette: "session/native-openai-tool-loop",
- protocol: "openai-responses",
- tags: ["kirincode", "native", "tool-loop"],
- canRecord: () => Boolean(envValue("KIRINCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY")),
- config: (model) =>
- providerConfig({
- providerID: ProviderV2.ID.openai,
- name: "OpenAI",
- env: ["OPENAI_API_KEY"],
- npm: "@ai-sdk/openai",
- api: "https://api.openai.com/v1",
- model,
- options: {
- apiKey: envValue("KIRINCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY") ?? "fixture-openai-key",
- baseURL: "https://api.openai.com/v1",
- },
- }),
- },
- {
- id: "openai-oauth",
- name: "OpenAI OAuth",
- providerID: ProviderV2.ID.openai,
- modelID: "gpt-5.5",
- cassette: "session/native-openai-oauth-tool-loop",
- protocol: "openai-responses",
- tags: ["kirincode", "native", "oauth", "tool-loop"],
- canRecord: () => recordOpenAIOAuth() !== undefined,
- recordAuth: recordOpenAIOAuth,
- replayAuth: replayOpenAIOAuth,
- stableID: "openai-oauth",
- config: (model) =>
- providerConfig({
- providerID: ProviderV2.ID.openai,
- name: "OpenAI",
- env: ["OPENAI_API_KEY"],
- npm: "@ai-sdk/openai",
- api: "https://api.openai.com/v1",
- model,
- options: { baseURL: "https://api.openai.com/v1" },
- }),
- },
- {
- id: "opencode-proxy",
- name: "KirinCode proxy",
- providerID: ProviderV2.ID.kirincode,
- modelID: "gpt-5.2-codex",
- cassette: "session/native-zen-tool-loop",
- protocol: "openai-responses",
- tags: ["kirincode", "zen", "native", "tool-loop"],
- canRecord: () => Boolean(process.env.KIRINCODE_RECORD_CONSOLE_TOKEN && process.env.KIRINCODE_RECORD_ZEN_ORG_ID),
- config: (model) =>
- providerConfig({
- providerID: ProviderV2.ID.kirincode,
- name: "KirinCode Zen",
- env: ["KIRINCODE_CONSOLE_TOKEN"],
- npm: "@ai-sdk/openai-compatible",
- api: zenURL(process.env.KIRINCODE_RECORD_ZEN_CONNECTION ?? "fixture"),
- model,
- options: {
- apiKey: process.env.KIRINCODE_RECORD_CONSOLE_TOKEN ?? "fixture-console-token",
- headers: { "x-org-id": process.env.KIRINCODE_RECORD_ZEN_ORG_ID ?? "fixture-org" },
- },
- }),
- },
- {
- id: "anthropic-api-key",
- name: "Anthropic API key",
- providerID: ProviderV2.ID.anthropic,
- modelID: "claude-haiku-4-5-20251001",
- cassette: "session/native-anthropic-tool-loop",
- protocol: "anthropic-messages",
- tags: ["kirincode", "native", "tool-loop"],
- canRecord: () => Boolean(envValue("KIRINCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")),
- config: (model) =>
- providerConfig({
- providerID: ProviderV2.ID.anthropic,
- name: "Anthropic",
- env: ["ANTHROPIC_API_KEY"],
- npm: "@ai-sdk/anthropic",
- api: "https://api.anthropic.com/v1",
- model,
- options: {
- apiKey: envValue("KIRINCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY") ?? "fixture-anthropic-key",
- baseURL: "https://api.anthropic.com/v1",
- },
- }),
- },
- ] satisfies ReadonlyArray<RecordedScenario>
- const shouldRecord = process.env.RECORD === "true"
- const selectedScenarios = new Set(
- (envValue("KIRINCODE_RECORDED_SCENARIO", "RECORDED_PROVIDER") ?? "")
- .split(",")
- .map((item) => item.trim().toLowerCase())
- .filter(Boolean),
- )
- function isSelected(scenario: RecordedScenario) {
- if (selectedScenarios.size === 0) return true
- return [scenario.id, scenario.name, scenario.providerID, scenario.cassette, ...scenario.tags]
- .map((item) => item.toLowerCase())
- .some((item) => selectedScenarios.has(item))
- }
- const canRun = (scenario: RecordedScenario) =>
- shouldRecord
- ? scenario.canRecord()
- : HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR })
- const recordError = (scenario: RecordedScenario) =>
- scenario.id === "openai-oauth"
- ? "Set KIRINCODE_RECORD_OPENAI_AUTH to an OAuth auth JSON object in the recording environment."
- : `Missing recording credentials for ${scenario.name}.`
- const redactRecordedBody = (body: string) =>
- body
- .replace(/wrk_[A-Z0-9]+/g, "wrk_redacted")
- .replace(/"safety_identifier"\s*:\s*"user-[^"]+"/g, '"safety_identifier":"user_redacted"')
- .replace(/"(access|access_token|refresh|refresh_token|accountId|account_id)"\s*:\s*"[^"]+"/g, '"$1":"redacted"')
- function authLayer(scenario: RecordedScenario) {
- const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth
- if (!replayAuth) return undefined
- return Layer.mock(Auth.Service)({
- get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined),
- all: () => Effect.succeed({ [scenario.providerID]: replayAuth }),
- })
- }
- async function loadFixture(providerID: string, modelID: string) {
- const data = await modelsFixture
- const provider = data[providerID]
- if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
- const model = provider.models[modelID]
- if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
- return model
- }
- const modelsFixture = Filesystem.readJson<Record<string, ModelsDev.Provider>>(
- path.join(import.meta.dir, "../tool/fixtures/models-api.json"),
- )
- function recordedNativeLLMLayer(scenario: RecordedScenario) {
- const auth = authLayer(scenario)
- // Only the HTTP client is recorded; RequestExecutor and the kirincode LLM stack remain real.
- const metadata = {
- provider: scenario.providerID,
- protocol: scenario.protocol,
- route: scenario.protocol,
- tags: scenario.tags,
- }
- const redact = {
- url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"),
- body: redactRecordedBody,
- }
- const recordedHttp = shouldRecord
- ? HttpRecorderInternal.cassetteLayer(scenario.cassette, {
- directory: FIXTURES_DIR,
- mode: "record",
- metadata,
- redactor: HttpRecorderInternal.Redactor.make(redact),
- })
- : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact })
- return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [
- [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))],
- [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })],
- ...(auth ? ([[Auth.node, auth]] as const) : []),
- ])
- }
- const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) =>
- Effect.promise(() =>
- Bun.write(
- path.join(directory, "kirincode.json"),
- JSON.stringify({ $schema: "https://kirincode.ai/config.json", ...scenario.config(model) }),
- ),
- )
- const collect = (input: LLM.StreamInput) =>
- Effect.gen(function* () {
- const llm = yield* LLM.Service
- return Array.from(yield* llm.stream(input).pipe(Stream.runCollect))
- })
- const WEATHER_RESULT = { temperature: 22, condition: "sunny" } as const
- const WEATHER_SYSTEM =
- "Use the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny."
- const WEATHER_USER = "What is the weather in Paris?"
- const weatherTool = tool({
- description: "Get the current weather for a city.",
- inputSchema: z.object({ city: z.string() }),
- execute: async () => WEATHER_RESULT,
- })
- const toolRoundtrip = (
- events: ReadonlyArray<LLMEvent>,
- call: { readonly id: string; readonly name: string; readonly input: unknown },
- result: JSONValue,
- ): ModelMessage[] => [
- {
- role: "assistant",
- content: [
- ...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
- type: "reasoning" as const,
- text: events
- .filter(LLMEvent.is.reasoningDelta)
- .filter((event) => event.id === part.id)
- .map((event) => event.text)
- .join(""),
- providerMetadata: part.providerMetadata,
- })),
- { type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
- ],
- },
- {
- role: "tool",
- content: [
- { type: "tool-result", toolCallId: call.id, toolName: call.name, output: { type: "json", value: result } },
- ],
- },
- ]
- const driveToolLoop = (scenario: RecordedScenario) =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const model = yield* Effect.promise(() => loadFixture(scenario.providerID, scenario.modelID))
- yield* writeConfig(test.directory, scenario, model)
- const stableID = scenario.stableID ?? scenario.providerID
- const sessionID = SessionID.make(`session-recorded-${stableID}-loop`)
- const modelID = ModelV2.ID.make(model.id)
- const agent = {
- name: "test",
- mode: "primary",
- prompt: "Answer using tools when appropriate.",
- options: {},
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- temperature: 0,
- } satisfies Agent.Info
- const provider = yield* Provider.Service
- const resolved = yield* provider.getModel(scenario.providerID, modelID)
- const userMessage = { role: "user", content: WEATHER_USER } satisfies ModelMessage
- const base = {
- user: {
- id: MessageID.make(`msg_user-recorded-${stableID}-loop`),
- sessionID,
- role: "user",
- time: { created: 0 },
- agent: agent.name,
- model: { providerID: scenario.providerID, modelID },
- } satisfies SessionV1.User,
- sessionID,
- model: resolved,
- agent,
- system: [WEATHER_SYSTEM],
- tools: { get_weather: weatherTool },
- }
- const turn1 = yield* collect({ ...base, messages: [userMessage] })
- const toolCall = turn1.find(LLMEvent.is.toolCall)
- expect(toolCall).toBeDefined()
- expect(turn1.find(LLMEvent.is.toolResult)).toBeDefined()
- expect(toolCall!.name).toBe("get_weather")
- expect(toolCall!.input).toMatchObject({ city: expect.stringMatching(/Paris/i) })
- expect(turn1.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
- const turn2 = yield* collect({
- ...base,
- messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
- })
- expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
- expect(turn2.filter(LLMEvent.is.finish)).toHaveLength(1)
- expect(turn2.filter(LLMEvent.is.toolCall)).toHaveLength(0)
- })
- describe("session.llm native recorded", () => {
- for (const scenario of RECORDED_SCENARIOS.filter(isSelected)) {
- if (!canRun(scenario)) {
- if (shouldRecord && scenario.recordAuth && selectedScenarios.size > 0) {
- test(`${scenario.name}: drives a tool loop to a final text answer`, () => {
- throw new Error(recordError(scenario))
- })
- continue
- }
- test.skip(`${scenario.name}: drives a tool loop to a final text answer`, () => {})
- continue
- }
- const it = testEffect(recordedNativeLLMLayer(scenario))
- it.instance(`${scenario.name}: drives a tool loop to a final text answer`, () => driveToolLoop(scenario))
- }
- })
|