refactor(ai): keep only the test doubles in use (#60809)

This commit is contained in:
Aiden Cline
2026-10-31 01:12:49 -05:00
committed by GitHub
Unverified
parent 994ade5bf6
commit 635fb7b14d
14 changed files with 129 additions and 452 deletions
+11 -8
View File
@@ -897,17 +897,17 @@ Everything below is exported from `@opencode/ai/effect` unless noted.
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
- **`ai.decision.request` / `ai.decision.generate`** — typed predicate, choice, and score questions over text, JSON, and media input; `DecisionClient` is its Effect service and layer.
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
- **`@opencode/ai`** (Promise root) — a default `ai` with the text calls (`request`, `generate`, `stream`, `loop`, `compact`, `canCompact`) at the top level plus `image` and `decision` (plus `experimental.video`, `experimental.speech`, and `experimental.transcription`) as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets. `AI.make({ layer?, clients? })` builds a client over a custom executor or test layers. The same `ai` from `@opencode/ai/effect` returns `Effect` and `Stream`.
- **`@opencode/ai`** (Promise root) — a default `ai` with the text calls (`request`, `generate`, `stream`, `loop`, `compact`, `canCompact`) at the top level plus `image` and `decision` (plus `experimental.video`, `experimental.speech`, and `experimental.transcription`) as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets. `AI.make({ layer? })` builds a client over a custom executor. The same `ai` from `@opencode/ai/effect` returns `Effect` and `Stream`.
- **`@opencode/ai/experimental`** — APIs that may change without compatibility guarantees: `Video`, `Speech`, `Transcription`, `Extraction`, and `Realtime`. See [Experimental](#experimental).
## Experimental
These APIs may change without compatibility guarantees. The Effect API is exported from `@opencode/ai/experimental`,
the Promise client exposes it under `ai.experimental`, and test layers come from `@opencode/ai/experimental/testing`.
and the Promise client exposes it under `ai.experimental`.
- **`Video`**, **`Speech`**, and **`Transcription`** — media generation and speech-to-text, with `VideoClient`,
`SpeechClient`, and `TranscriptionClient` as their Effect services. Promise: `ai.experimental.video`, `.speech`, and
`.transcription`. Testing: `TestVideo`, `TestSpeech`, and `TestTranscription`.
`.transcription`.
- **`Extraction`** — typed values read out of text.
- **`Realtime`** — `Realtime.connect` starts an OpenAI, xAI, or Gemini voice session and returns a WebRTC answer SDP or
a short-lived WebSocket URL and token.
@@ -1236,13 +1236,16 @@ For explicit compaction, script a `CompactionResponse` through `push`, `always`,
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
`TestImage` and `TestDecision` (and `TestVideo`, `TestSpeech`, and `TestTranscription` from `@opencode/ai/experimental/testing`) follow the same pattern: `testLayer()` provides the modality's client and `Test`, with the same `push`, `always`, `serve`, `requests`, `wait`, and `gate` controls. Script an `ImageResponse` (or the modality's response) or an `AIError`. `stream` expands the response into its events, and `start` returns an already finished generation that `resume` accepts by token. Decision responses are returned as scripted, without checking the answers against the questions.
Response helpers build common event arrays: `TestLLM.text(value, id)`, `textWithUsage(value, id, inputTokens)`,
`toolCall(id, name, input)`, `stop(...events)`, `toolCalls(...events)`, and
`complete({ reason, usage?, providerMetadata? }, ...events)`. `failAfter(error, ...events)` and `hangAfter(...events)`
return streams that fail or never finish after the given events.
The Promise client accepts the same layers as `AI.make({ clients: TestImage.testLayer() })`; `ai.run(TestImage.Test.use(...))` reaches the controls.
`compileRequest`, also from `@opencode/ai/testing`, compiles an `LLMRequest` through its route without sending it, so
tests can assert the provider payload.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
Other modalities and the Promise client have no test double. Test them over a custom executor layer, such as
`AI.make({ layer })`, that answers the HTTP requests.
## Provider compaction
-1
View File
@@ -20,7 +20,6 @@
"./effect": "./src/effect.ts",
"./testing": "./src/testing.ts",
"./experimental": "./src/experimental.ts",
"./experimental/testing": "./src/experimental/testing.ts",
"./route": "./src/route.ts",
"./providers": "./src/providers.ts",
"./providers/alibaba": "./src/providers/alibaba.ts",
-3
View File
@@ -1,3 +0,0 @@
export { TestSpeech } from "../testing/speech.js"
export { TestTranscription } from "../testing/transcription.js"
export { TestVideo } from "../testing/video.js"
+6 -14
View File
@@ -50,11 +50,9 @@ import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } fro
* transcription and decision clients over a request executor; every method runs the corresponding Effect API and rethrows
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
*/
export interface Options<R = never> {
export interface Options {
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
readonly layer?: Layer.Layer<RequestExecutor.Service>
/** Replaces default clients, for example with `TestImage.testLayer()`; its other services are available to `run`. */
readonly clients?: Layer.Layer<R>
}
export interface RunOptions {
@@ -95,20 +93,14 @@ const abortEffect = (signal: AbortSignal | undefined) =>
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
})
export const make = <R = never>(options: Options<R> = {}) => {
const runtime = ManagedRuntime.make(
Layer.merge(
AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer),
// `R` is only inferred from `clients`, so it is `never` whenever `clients` is omitted.
(options.clients ?? Layer.empty) as Layer.Layer<R>,
),
)
export const make = (options: Options = {}) => {
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
/** Run any package Effect (for example `ai.compact(...)` from `@opencode/ai/effect`) inside this runtime. */
const run = <A, E>(effect: Effect.Effect<A, E, Services | R>, options?: RunOptions) =>
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
runtime.runPromise(Effect.raceFirst(effect, abortEffect(options?.signal)))
const iterate = <A, E>(stream: Stream.Stream<A, E, Services | R>, options?: RunOptions): AsyncIterable<A> =>
const iterate = <A, E>(stream: Stream.Stream<A, E, Services>, options?: RunOptions): AsyncIterable<A> =>
Stream.toAsyncIterable(
Stream.unwrap(
runtime.contextEffect.pipe(
@@ -307,7 +299,7 @@ export const make = <R = never>(options: Options<R> = {}) => {
}
}
export type Client<R = never> = ReturnType<typeof make<R>>
export type Client = ReturnType<typeof make>
const fileError = (message: string, cause: unknown) =>
new AIError({ reason: new InvalidRequestError({ message, cause }) })
+87 -8
View File
@@ -1,7 +1,5 @@
export * as TestLLM from "./testing.js"
export { compileRequest } from "./route/client.js"
export { TestDecision } from "./testing/decision.js"
export { TestImage } from "./testing/image.js"
import {
LLMClient,
@@ -21,8 +19,7 @@ import {
type ProviderMetadata,
type UsageInput,
} from "./schema/index.js"
import { Context, Effect, Layer, Stream } from "effect"
import { TestScript } from "./testing/script.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response =
| readonly LLMEvent[]
@@ -30,17 +27,34 @@ export type Response =
| CompactionResponse
| CompactionCheckpointResponse
export type Gate = TestScript.Gate
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
export type Responder = (request: LLMRequest) => Response
export interface TestInterface extends ClientInterface, TestScript.Controls<LLMRequest, Response> {}
interface Controls {
/** Returns a snapshot of requests observed at execution time. */
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
/** Replaces the fallback without changing queued responses. */
readonly always: (response: Response) => Effect.Effect<void>
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
readonly serve: (responder: Responder) => Effect.Effect<void>
/** Waits for request arrivals, not output or completion. */
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
}
export interface TestInterface extends ClientInterface, Controls {}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
export type LayerOptions = TestScript.Options<LLMRequest, Response>
export interface LayerOptions {
readonly transformRequest?: (request: LLMRequest) => LLMRequest
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
readonly fallback?: Response
}
export const complete = (
options: {
@@ -89,7 +103,7 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
const make = (options: LayerOptions) =>
Effect.sync(() => {
const script = TestScript.make<LLMRequest, Response>("TestLLM", options)
const script = makeScript(options)
const stream: ClientInterface["stream"] = (request) =>
Stream.unwrap(
script.take(request).pipe(
@@ -137,3 +151,68 @@ export const testLayer = (options: LayerOptions = {}) =>
Layer.effectContext(
Effect.map(make(options), (test) => Context.make(LLMClient.Service, test).pipe(Context.add(Test, test))),
)
/** The response queue, fallback, request log, and gates behind the test controls. */
const makeScript = (options: LayerOptions) => {
const requests: LLMRequest[] = []
const responses: Response[] = []
let started = Deferred.makeUnsafe<void>()
const initial = options.fallback
let fallback: Responder | undefined = initial === undefined ? undefined : () => initial
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const take = (request: LLMRequest) =>
Effect.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
const gate = activeGate
try {
const response = responses.shift() ?? fallback?.(request)
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
if (!gate) return Effect.succeed(response)
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
})
const controls: Controls = {
requests: () => Effect.sync(() => [...requests]),
push: (...input) =>
Effect.sync(() => {
responses.push(...input)
}),
always: (response) =>
Effect.sync(() => {
fallback = () => response
}),
serve: (responder) =>
Effect.sync(() => {
fallback = responder
}),
wait,
gate: () =>
Effect.gen(function* () {
const gate = {
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
release: yield* Latch.make(),
}
activeGate = gate
const release = Effect.sync(() => {
if (activeGate === gate) activeGate = undefined
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
yield* Effect.addFinalizer(() => release)
return {
started: Queue.take(gate.started),
release,
}
}),
}
return { take, controls }
}
-38
View File
@@ -1,38 +0,0 @@
import { Context, Effect, Layer } from "effect"
import { DecisionClient, type Interface } from "../decision-client.js"
import type { DecisionRequestFor, DecisionResponse, DecisionResponseFor } from "../decision.js"
import { AIError } from "../schema/index.js"
import { TestScript } from "./script.js"
/** Returned as scripted, without checking its answers against the request's questions. */
export type Response = DecisionResponse | AIError
export interface TestInterface extends Interface, TestScript.Controls<DecisionRequestFor, Response> {}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestDecision/Test") {}
export type LayerOptions = TestScript.Options<DecisionRequestFor, Response>
/** Provides one shared implementation under `DecisionClient.Service` and `TestDecision.Test`. */
export const testLayer = (options: LayerOptions = {}) =>
Layer.effectContext(
Effect.sync(() => {
const script = TestScript.make("TestDecision", options)
const client = Test.of({
...script.controls,
generate: (request) =>
script
.take(request)
.pipe(
Effect.flatMap((response) =>
response instanceof AIError
? Effect.fail(response)
: Effect.succeed(response as DecisionResponseFor<typeof request.questions>),
),
),
})
return Context.make(DecisionClient.Service, client).pipe(Context.add(Test, client))
}),
)
export * as TestDecision from "./decision.js"
-23
View File
@@ -1,23 +0,0 @@
import { Context } from "effect"
import { ImageClient } from "../image-client.js"
import type { ImageEvent, ImageRequestFor, ImageResponse } from "../image.js"
import { TestMedia } from "./media.js"
import type { TestScript } from "./script.js"
export type Response = TestMedia.Response<ImageResponse>
export interface TestInterface extends TestMedia.TestInterface<ImageRequestFor, ImageEvent, ImageResponse> {}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestImage/Test") {}
export type LayerOptions = TestScript.Options<ImageRequestFor, Response>
/** Provides one shared implementation under `ImageClient.Service` and `TestImage.Test`. */
export const testLayer = (options: LayerOptions = {}) =>
TestMedia.testLayer(ImageClient.Service, Test, {
name: "TestImage",
responseEvents: ImageClient.responseEvents,
options,
})
export * as TestImage from "./image.js"
-60
View File
@@ -1,60 +0,0 @@
import { Context, Effect, Layer, Stream } from "effect"
import { Generation } from "../generation.js"
import type { MediaClient } from "../media-client.js"
import { AIError } from "../schema/index.js"
import { TestScript } from "./script.js"
/** A scripted reply: the response every operation returns, or the error it fails with. */
export type Response<Value> = Value | AIError
export interface TestInterface<Req extends MediaClient.RoutedRequest<Req, Event, Value>, Event, Value>
extends MediaClient.Interface<Req, Event, Value>,
TestScript.Controls<Req, Response<Value>> {}
/**
* Builds one media test client. `stream` expands the response like an inline route, and `start` returns an already
* finished generation whose token `resume` accepts.
*/
export const testLayer = <Self, Test, Req extends MediaClient.RoutedRequest<Req, Event, Value>, Event, Value>(
service: Context.Service<Self, MediaClient.Interface<Req, Event, Value>>,
test: Context.Service<Test, TestInterface<Req, Event, Value>>,
input: {
readonly name: string
readonly responseEvents: (response: Value) => ReadonlyArray<Event>
readonly options: TestScript.Options<Req, Response<Value>>
},
) =>
Layer.effectContext(
Effect.sync(() => {
const script = TestScript.make(input.name, input.options)
const generations = new Map<string, Response<Value>>()
const result = (response: Response<Value>) =>
response instanceof AIError ? Effect.fail(response) : Effect.succeed(response)
const completed = (id: string, response: Response<Value>) => {
const snapshot = { id, status: response instanceof AIError ? "failed" : "completed" } as const
return new Generation({ status: Effect.succeed(snapshot), result: result(response) }, id, snapshot)
}
const generate = (request: Req) => script.take(request).pipe(Effect.flatMap(result))
const client = test.of({
...script.controls,
generate,
stream: (request) => Stream.fromIterableEffect(Effect.map(generate(request), input.responseEvents)),
start: (request) =>
script.take(request).pipe(
Effect.map((response) => {
const id = `${input.name}-${generations.size + 1}`
generations.set(id, response)
return completed(id, response)
}),
),
resume: (_model, token) => {
const response = typeof token === "string" ? generations.get(token) : undefined
if (response === undefined) return Effect.die(new Error(`${input.name} has no generation ${String(token)}`))
return Effect.succeed(completed(String(token), response))
},
})
return Context.make(service, client).pipe(Context.add(test, client))
}),
)
export * as TestMedia from "./media.js"
-89
View File
@@ -1,89 +0,0 @@
import { Deferred, Effect, Latch, Queue, Scope } from "effect"
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
export interface Controls<Request, Response> {
/** Returns a snapshot of requests observed at execution time. */
readonly requests: () => Effect.Effect<readonly Request[]>
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
/** Replaces the fallback without changing queued responses. */
readonly always: (response: Response) => Effect.Effect<void>
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
readonly serve: (responder: (request: Request) => Response) => Effect.Effect<void>
/** Waits for request arrivals, not output or completion. */
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
}
export interface Options<Request, Response> {
readonly transformRequest?: (request: Request) => Request
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
readonly fallback?: Response
}
/** The response queue, fallback, request log, and gates shared by every test client. */
export const make = <Request, Response>(name: string, options: Options<Request, Response>) => {
const requests: Request[] = []
const responses: Response[] = []
let started = Deferred.makeUnsafe<void>()
const initial = options.fallback
let fallback: ((request: Request) => Response) | undefined = initial === undefined ? undefined : () => initial
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const take = (request: Request) =>
Effect.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
const gate = activeGate
try {
const response = responses.shift() ?? fallback?.(request)
if (!response) return Effect.die(new Error(`${name} has no response for request ${count}`))
if (!gate) return Effect.succeed(response)
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
})
const controls: Controls<Request, Response> = {
requests: () => Effect.sync(() => [...requests]),
push: (...input) =>
Effect.sync(() => {
responses.push(...input)
}),
always: (response) =>
Effect.sync(() => {
fallback = () => response
}),
serve: (responder) =>
Effect.sync(() => {
fallback = responder
}),
wait,
gate: () =>
Effect.gen(function* () {
const gate = {
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
release: yield* Latch.make(),
}
activeGate = gate
const release = Effect.sync(() => {
if (activeGate === gate) activeGate = undefined
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
yield* Effect.addFinalizer(() => release)
return {
started: Queue.take(gate.started),
release,
}
}),
}
return { take, controls }
}
export * as TestScript from "./script.js"
-23
View File
@@ -1,23 +0,0 @@
import { Context } from "effect"
import { SpeechClient } from "../speech-client.js"
import type { SpeechEvent, SpeechRequestFor, SpeechResponse } from "../speech.js"
import { TestMedia } from "./media.js"
import type { TestScript } from "./script.js"
export type Response = TestMedia.Response<SpeechResponse>
export interface TestInterface extends TestMedia.TestInterface<SpeechRequestFor, SpeechEvent, SpeechResponse> {}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestSpeech/Test") {}
export type LayerOptions = TestScript.Options<SpeechRequestFor, Response>
/** Provides one shared implementation under `SpeechClient.Service` and `TestSpeech.Test`. */
export const testLayer = (options: LayerOptions = {}) =>
TestMedia.testLayer(SpeechClient.Service, Test, {
name: "TestSpeech",
responseEvents: SpeechClient.responseEvents,
options,
})
export * as TestSpeech from "./speech.js"
-24
View File
@@ -1,24 +0,0 @@
import { Context } from "effect"
import { TranscriptionClient } from "../transcription-client.js"
import type { TranscriptionEvent, TranscriptionRequestFor, TranscriptionResponse } from "../transcription.js"
import { TestMedia } from "./media.js"
import type { TestScript } from "./script.js"
export type Response = TestMedia.Response<TranscriptionResponse>
export interface TestInterface
extends TestMedia.TestInterface<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse> {}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestTranscription/Test") {}
export type LayerOptions = TestScript.Options<TranscriptionRequestFor, Response>
/** Provides one shared implementation under `TranscriptionClient.Service` and `TestTranscription.Test`. */
export const testLayer = (options: LayerOptions = {}) =>
TestMedia.testLayer(TranscriptionClient.Service, Test, {
name: "TestTranscription",
responseEvents: TranscriptionClient.responseEvents,
options,
})
export * as TestTranscription from "./transcription.js"
-23
View File
@@ -1,23 +0,0 @@
import { Context } from "effect"
import { VideoClient } from "../video-client.js"
import type { VideoEvent, VideoRequestFor, VideoResponse } from "../video.js"
import { TestMedia } from "./media.js"
import type { TestScript } from "./script.js"
export type Response = TestMedia.Response<VideoResponse>
export interface TestInterface extends TestMedia.TestInterface<VideoRequestFor, VideoEvent, VideoResponse> {}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestVideo/Test") {}
export type LayerOptions = TestScript.Options<VideoRequestFor, Response>
/** Provides one shared implementation under `VideoClient.Service` and `TestVideo.Test`. */
export const testLayer = (options: LayerOptions = {}) =>
TestMedia.testLayer(VideoClient.Service, Test, {
name: "TestVideo",
responseEvents: VideoClient.responseEvents,
options,
})
export * as TestVideo from "./video.js"
+25 -3
View File
@@ -1,10 +1,14 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Layer, Schema, Stream } from "effect"
import { ai, LanguageModel, LLMEvent, ProviderInternalError, tool } from "../src/effect.js"
import { AI, AIError } from "../src/index.js"
import { OpenAIChat } from "../src/protocols/openai-chat.js"
import { OpenAI } from "../src/providers.js"
import { RequestExecutor } from "../src/route.js"
import { TestLLM } from "../src/testing.js"
import { testEffect } from "./lib/effect.js"
import { handlerLayer } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const model = LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route })
const it = testEffect(TestLLM.testLayer())
@@ -95,9 +99,27 @@ describe("AIError.partial", () => {
describe("AIError.partial from the promise client", () => {
test("rejects with the root-exported AIError carrying partial", async () => {
const client = AI.make({ clients: TestLLM.testLayer({ fallback: textThenFail }) })
const client = AI.make({
layer: RequestExecutor.layer.pipe(
Layer.provide(
handlerLayer((input) =>
Effect.succeed(
input.respond(
sseEvents(
{ choices: [{ delta: { content: "Partial answer" } }] },
{ error: { message: "Overloaded" } },
),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
),
),
})
const error = await client.generate({ model, prompt: "Hello" }).catch((error: unknown) => error)
const error = await client
.generate({ model: OpenAI.configure({ apiKey: "test" }).chat("gpt-4o-mini"), prompt: "Hello" })
.catch((error: unknown) => error)
expect(error).toBeInstanceOf(AIError)
expect(error instanceof AIError && error.partial?.text).toBe("Partial answer")
-135
View File
@@ -1,135 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { ai, AIError, DecisionResponse, ImageResponse, Media, RateLimitError } from "../src/effect.js"
import {
Speech,
SpeechResponse,
Transcription,
TranscriptionResponse,
Video,
VideoResponse,
} from "../src/experimental.js"
import { AI } from "../src/index.js"
import { OpenAI, Runway } from "../src/providers.js"
import { TestDecision, TestImage } from "../src/testing.js"
import { TestSpeech, TestTranscription, TestVideo } from "../src/experimental/testing.js"
import { testEffect } from "./lib/effect.js"
const openai = OpenAI.configure({ apiKey: "test" })
const runway = Runway.configure({ apiKey: "test" }).video("gen4.5")
const png = Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")
const audio = Media.bytes(Uint8Array.from([4, 5, 6]), "audio/pcm")
const mp4 = Media.url("https://video.test/out.mp4")
const question = { name: "red", type: "predicate", instructions: "Is it red?" } as const
const answer = new DecisionResponse({ answers: [{ name: "red", type: "predicate", probability: 0.9 }] })
const doubles = () =>
Layer.mergeAll(
TestImage.testLayer(),
TestVideo.testLayer(),
TestSpeech.testLayer(),
TestTranscription.testLayer(),
TestDecision.testLayer(),
)
const it = testEffect(doubles())
describe("media test clients", () => {
it.effect("script image responses and failures and record requests", () =>
Effect.gen(function* () {
const test = yield* TestImage.Test
const failure = new AIError({ reason: new RateLimitError({ message: "Try later" }) })
yield* test.push(new ImageResponse({ images: [png] }), failure)
yield* test.always(new ImageResponse({ images: [png, png] }))
expect((yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })).image).toBe(png)
expect(yield* ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A kite" }).pipe(Effect.flip)).toBe(
failure,
)
const events = yield* Stream.runCollect(ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A boat" }))
expect(Array.from(events, (event) => event.type)).toEqual(["image", "image", "finish"])
expect((yield* test.requests()).map((request) => request.prompt)).toEqual(["A lighthouse", "A kite", "A boat"])
}),
)
it.effect("starts completed video generations that resume from their token", () =>
Effect.gen(function* () {
const test = yield* TestVideo.Test
yield* test.serve((request) => new VideoResponse({ videos: request.prompt === "A kite" ? [mp4] : [] }))
const generation = yield* Video.start({ model: runway, prompt: "A kite" })
expect(generation.status).toBe("completed")
expect((yield* generation.await()).video).toBe(mp4)
const resumed = yield* Video.resume(runway, JSON.parse(JSON.stringify(generation.token)))
expect((yield* resumed.result()).video).toBe(mp4)
expect(yield* test.requests()).toHaveLength(1)
expect(yield* Video.resume(runway, "unknown").pipe(Effect.catchDefect(Effect.succeed))).toMatchObject({
message: "TestVideo has no generation unknown",
})
}),
)
it.effect("script speech, transcription, and decision responses", () =>
Effect.gen(function* () {
yield* (yield* TestSpeech.Test).push(new SpeechResponse({ audio }))
yield* (yield* TestTranscription.Test).push(new TranscriptionResponse({ text: "Hello there." }))
const decision = yield* TestDecision.Test
yield* decision.push(answer)
const speech = yield* Stream.runCollect(Speech.stream({ model: openai.speech("gpt-4o-mini-tts"), text: "Hi" }))
expect(Array.from(speech, (event) => event.type)).toEqual(["finish"])
expect(
(yield* Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio })).text,
).toBe("Hello there.")
const response = yield* ai.decision.generate({
model: openai.decision("decision-model"),
input: [{ type: "text", text: "A red apple" }],
questions: [question],
})
expect(response.answers).toEqual([{ name: "red", type: "predicate", probability: 0.9 }])
expect((yield* decision.requests())[0].questions).toEqual([question])
expect(
yield* Speech.generate({ model: openai.speech("gpt-4o-mini-tts"), text: "Hi" }).pipe(
Effect.catchDefect(Effect.succeed),
),
).toMatchObject({ message: "TestSpeech has no response for request 2" })
}),
)
test("replace the Promise client's defaults and expose their controls to run", async () => {
const client = AI.make({ clients: doubles() })
await client.run(
Effect.gen(function* () {
yield* (yield* TestImage.Test).push(new ImageResponse({ images: [png] }))
yield* (yield* TestVideo.Test).push(new VideoResponse({ videos: [mp4] }))
yield* (yield* TestSpeech.Test).push(new SpeechResponse({ audio }))
yield* (yield* TestTranscription.Test).push(new TranscriptionResponse({ text: "Hello there." }))
yield* (yield* TestDecision.Test).push(answer)
}),
)
expect((await client.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })).image).toBe(
png,
)
const generation = await client.experimental.video.start({ model: runway, prompt: "A kite" })
expect(
(await client.experimental.video.resume(runway, generation.token).then((resumed) => resumed.await())).video,
).toBe(mp4)
expect(
await client.bytes(
(await client.experimental.speech.generate({ model: openai.speech("gpt-4o-mini-tts"), text: "Hi" })).audio,
),
).toEqual(Uint8Array.from([4, 5, 6]))
const deltas = await Array.fromAsync(
client.experimental.transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }),
)
expect(deltas.map((event) => event.type)).toEqual(["finish"])
const decided = await client.decision.generate({
model: openai.decision("decision-model"),
input: [{ type: "text", text: "A red apple" }],
questions: [question],
})
expect(decided.answers).toEqual([{ name: "red", type: "predicate", probability: 0.9 }])
expect((await client.run(TestImage.Test.use((test) => test.requests())))[0].prompt).toBe("A lighthouse")
await client.dispose()
})
})