fix: 修正 logo 中 N 和 G 字母造型
N 添加对角线笔画(█▄ █),G 添加内横杠(█ ▀█), 避免与 O 字母造型雷同。同步更新 ui.ts 中的硬编码 wordmark。
This commit is contained in:
50
packages/llm/test/lib/effect.ts
Normal file
50
packages/llm/test/lib/effect.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import * as TestConsole from "effect/testing/TestConsole"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||
|
||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
for (const err of Cause.prettyErrors(exit.cause)) {
|
||||
yield* Effect.logError(err)
|
||||
}
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise)
|
||||
|
||||
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
|
||||
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test(name, () => run(value, testLayer), opts)
|
||||
|
||||
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.only(name, () => run(value, testLayer), opts)
|
||||
|
||||
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.skip(name, () => run(value, testLayer), opts)
|
||||
|
||||
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test(name, () => run(value, liveLayer), opts)
|
||||
|
||||
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.only(name, () => run(value, liveLayer), opts)
|
||||
|
||||
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.skip(name, () => run(value, liveLayer), opts)
|
||||
|
||||
return { effect, live }
|
||||
}
|
||||
|
||||
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
||||
const liveEnv = TestConsole.layer
|
||||
|
||||
export const it = make(testEnv, liveEnv)
|
||||
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
|
||||
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|
||||
98
packages/llm/test/lib/http.ts
Normal file
98
packages/llm/test/lib/http.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import type { Service as LLMClientService } from "../../src/route/client"
|
||||
import type { Service as RequestExecutorService } from "../../src/route/executor"
|
||||
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket"
|
||||
|
||||
export type HandlerInput = {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly text: string
|
||||
readonly respond: (
|
||||
body: ConstructorParameters<typeof Response>[0],
|
||||
init?: ResponseInit,
|
||||
) => HttpClientResponse.HttpClientResponse
|
||||
}
|
||||
|
||||
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
|
||||
|
||||
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
const text = yield* Effect.promise(() => web.text())
|
||||
return yield* handler({
|
||||
request,
|
||||
text,
|
||||
respond: (body, init) => HttpClientResponse.fromWeb(request, new Response(body, init)),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
||||
|
||||
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
||||
return Layer.mergeAll(deps, llmClientLayer)
|
||||
}
|
||||
|
||||
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
||||
|
||||
/**
|
||||
* Layer that returns a single fixed response body. Use for stream-parser
|
||||
* fixture tests where the request shape is irrelevant. The body type widens
|
||||
* to whatever `Response` accepts so binary fixtures (`Uint8Array`,
|
||||
* `ReadableStream`, etc.) flow through without casts.
|
||||
*/
|
||||
export const fixedResponse = (
|
||||
body: ConstructorParameters<typeof Response>[0],
|
||||
init: ResponseInit = { headers: SSE_HEADERS },
|
||||
) => runtimeLayer(handlerLayer((input) => Effect.succeed(input.respond(body, init))))
|
||||
|
||||
/**
|
||||
* Layer that builds a response per request. Useful for echo servers.
|
||||
*/
|
||||
export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(handler))
|
||||
|
||||
/**
|
||||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||
* exercise transport errors that surface during parsing.
|
||||
*/
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||
dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.error(new Error("connection reset"))
|
||||
},
|
||||
})
|
||||
return input.respond(stream, { headers: SSE_HEADERS })
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Layer that returns successive bodies on each request. Useful for scripting
|
||||
* multi-step model exchanges (e.g. tool-call loops). The last body in the
|
||||
* array is reused if the test makes more requests than scripted.
|
||||
*/
|
||||
export const scriptedResponses = (bodies: ReadonlyArray<string>, init: ResponseInit = { headers: SSE_HEADERS }) => {
|
||||
if (bodies.length === 0) throw new Error("scriptedResponses requires at least one body")
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* Ref.make(0)
|
||||
return dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
||||
return input.respond(bodies[index] ?? bodies[bodies.length - 1], init)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
27
packages/llm/test/lib/openai-chunks.ts
Normal file
27
packages/llm/test/lib/openai-chunks.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Shared chunk shapes for OpenAI Chat / OpenAI-compatible Chat fixture tests.
|
||||
* Multiple test files build the same `{ id, choices: [{ delta, finish_reason }], usage }`
|
||||
* envelope; consolidating here keeps tool-call event shapes consistent.
|
||||
*/
|
||||
|
||||
const FIXTURE_ID = "chatcmpl_fixture"
|
||||
|
||||
export const deltaChunk = (delta: object, finishReason: string | null = null) => ({
|
||||
id: FIXTURE_ID,
|
||||
choices: [{ delta, finish_reason: finishReason }],
|
||||
usage: null,
|
||||
})
|
||||
|
||||
export const usageChunk = (usage: object) => ({
|
||||
id: FIXTURE_ID,
|
||||
choices: [],
|
||||
usage,
|
||||
})
|
||||
|
||||
export const finishChunk = (reason: string) => deltaChunk({}, reason)
|
||||
|
||||
export const toolCallChunk = (id: string, name: string, args: string, index = 0) =>
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [{ index, id, function: { name, arguments: args } }],
|
||||
})
|
||||
17
packages/llm/test/lib/sse.ts
Normal file
17
packages/llm/test/lib/sse.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Helpers for building deterministic SSE bodies in tests.
|
||||
*
|
||||
* Inline template-literal SSE strings are hard to write and review when chunks
|
||||
* contain JSON; this helper accepts plain values and serializes them, so test
|
||||
* authors only think about the chunk shapes, not the wire format.
|
||||
*/
|
||||
export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
|
||||
`${chunks.map(formatChunk).join("")}data: [DONE]\n\n`
|
||||
|
||||
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
|
||||
|
||||
/**
|
||||
* Build an SSE body from already-serialized strings (used when the chunk shape
|
||||
* itself is part of what's being tested, e.g. malformed chunks).
|
||||
*/
|
||||
export const sseRaw = (...lines: ReadonlyArray<string>): string => lines.map((line) => `${line}\n\n`).join("")
|
||||
146
packages/llm/test/lib/tool-runtime.ts
Normal file
146
packages/llm/test/lib/tool-runtime.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
ToolResultPart,
|
||||
type ToolResultValue,
|
||||
type Usage,
|
||||
} from "../../src/schema"
|
||||
import { type Tools, toDefinitions } from "../../src/tool"
|
||||
import { ToolRuntime } from "../../src/tool-runtime"
|
||||
|
||||
interface RunOptions<T extends Tools> {
|
||||
readonly request: LLMRequest
|
||||
readonly tools: T
|
||||
readonly maxSteps?: number
|
||||
}
|
||||
|
||||
/** Test-owned continuation loop. Production callers must own durable history. */
|
||||
export const runTools = <T extends Tools>(options: RunOptions<T>) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const names = new Set(Object.keys(options.tools))
|
||||
let request = LLMRequest.update(options.request, {
|
||||
tools: [...options.request.tools.filter((tool) => !names.has(tool.name)), ...toDefinitions(options.tools)],
|
||||
})
|
||||
let usage: Usage | undefined
|
||||
const events: LLMEvent[] = []
|
||||
|
||||
for (let step = 0; step < (options.maxSteps ?? 10); step++) {
|
||||
const streamed = Array.from(yield* LLMClient.stream(request).pipe(Stream.runCollect))
|
||||
const state = stepState(streamed)
|
||||
usage = addUsage(usage, state.usage)
|
||||
events.push(...streamed.filter((event) => event.type !== "finish").map((event) => indexStep(event, step)))
|
||||
|
||||
if (state.toolCalls.length === 0) {
|
||||
events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata }))
|
||||
return Stream.fromIterable(events)
|
||||
}
|
||||
|
||||
const dispatched = yield* Effect.forEach(
|
||||
state.toolCalls,
|
||||
(call) => ToolRuntime.dispatch(options.tools, call).pipe(Effect.map((result) => [call, result] as const)),
|
||||
{ concurrency: 10 },
|
||||
)
|
||||
events.push(...dispatched.flatMap(([, result]) => result.events))
|
||||
|
||||
if (step + 1 >= (options.maxSteps ?? 10)) {
|
||||
events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata }))
|
||||
return Stream.fromIterable(events)
|
||||
}
|
||||
|
||||
request = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
Message.assistant(state.assistantContent),
|
||||
...dispatched.map(([call, dispatched]) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
|
||||
),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return Stream.fromIterable(events)
|
||||
}),
|
||||
)
|
||||
|
||||
const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
||||
if (event.type === "step-start") return LLMEvent.stepStart({ index })
|
||||
if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index })
|
||||
return event
|
||||
}
|
||||
|
||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const assistantContent: ContentPart[] = []
|
||||
const toolCalls: ToolCallPart[] = []
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
|
||||
let usage: Usage | undefined
|
||||
let providerMetadata: ProviderMetadata | undefined
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
|
||||
} else if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
|
||||
} else if (event.type === "tool-call") {
|
||||
assistantContent.push(event)
|
||||
if (!event.providerExecuted) toolCalls.push(event)
|
||||
} else if (event.type === "tool-result" && event.providerExecuted && event.result !== undefined) {
|
||||
assistantContent.push(
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: event.result,
|
||||
providerExecuted: true,
|
||||
providerMetadata: event.providerMetadata,
|
||||
}),
|
||||
)
|
||||
} else if (event.type === "finish") {
|
||||
reason = event.reason
|
||||
usage = event.usage
|
||||
providerMetadata = event.providerMetadata
|
||||
}
|
||||
}
|
||||
return { assistantContent, toolCalls, reason, usage, providerMetadata }
|
||||
}
|
||||
|
||||
const appendText = (
|
||||
content: ContentPart[],
|
||||
type: "text" | "reasoning",
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
) => {
|
||||
const last = content.at(-1)
|
||||
if (last?.type === type) {
|
||||
content[content.length - 1] = {
|
||||
...last,
|
||||
text: `${last.text}${text}`,
|
||||
providerMetadata: providerMetadata ?? last.providerMetadata,
|
||||
}
|
||||
return
|
||||
}
|
||||
content.push({ type, text, providerMetadata })
|
||||
}
|
||||
|
||||
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
const sum = (key: keyof Usage) =>
|
||||
typeof left[key] !== "number" && typeof right[key] !== "number"
|
||||
? undefined
|
||||
: ((left[key] as number | undefined) ?? 0) + ((right[key] as number | undefined) ?? 0)
|
||||
return {
|
||||
inputTokens: sum("inputTokens"),
|
||||
outputTokens: sum("outputTokens"),
|
||||
nonCachedInputTokens: sum("nonCachedInputTokens"),
|
||||
cacheReadInputTokens: sum("cacheReadInputTokens"),
|
||||
cacheWriteInputTokens: sum("cacheWriteInputTokens"),
|
||||
reasoningTokens: sum("reasoningTokens"),
|
||||
totalTokens: sum("totalTokens"),
|
||||
} as Usage
|
||||
}
|
||||
Reference in New Issue
Block a user