feat(ai): add Z.ai image generation (#37780)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
parent
4c9a4309ce
commit
fa4ec98e1d
8 changed files with 391 additions and 2 deletions
|
|
@ -110,6 +110,26 @@ future string values and arbitrary native Gemini `generationConfig` fields remai
|
||||||
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
|
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
|
||||||
`generateContent` without a local allowlist.
|
`generateContent` without a local allowlist.
|
||||||
|
|
||||||
|
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "user-123",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||||
|
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
|
||||||
|
remain available.
|
||||||
|
|
||||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -210,7 +230,7 @@ const gateway = CloudflareAIGateway.configure({
|
||||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||||
```
|
```
|
||||||
|
|
||||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||||
|
|
||||||
### Package-like entrypoints
|
### Package-like entrypoints
|
||||||
|
|
||||||
|
|
|
||||||
129
packages/ai/src/protocols/zai-images.ts
Normal file
129
packages/ai/src/protocols/zai-images.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
|
||||||
|
const ADAPTER = "zai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
|
export type ZAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type ZAIImageOptions = {
|
||||||
|
readonly size?: ZAIImageString<
|
||||||
|
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
||||||
|
>
|
||||||
|
readonly quality?: ZAIImageString<"hd" | "standard">
|
||||||
|
readonly userID?: string
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type ZAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZAIImageResponse = Schema.Struct({
|
||||||
|
created: Schema.optional(Schema.Int),
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
request_id: Schema.optional(Schema.String),
|
||||||
|
data: Schema.Array(Schema.Struct({ url: Schema.String })),
|
||||||
|
content_filter: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
role: Schema.optional(Schema.String),
|
||||||
|
level: Schema.optional(Schema.Number),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { userID, ...native } = options
|
||||||
|
return {
|
||||||
|
user_id: userID,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<ZAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as ZAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images: decoded.data.map(
|
||||||
|
(item) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
data: item.url,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
providerMetadata: {
|
||||||
|
zai: {
|
||||||
|
created: decoded.created,
|
||||||
|
id: decoded.id,
|
||||||
|
requestID: decoded.request_id,
|
||||||
|
contentFilter: decoded.content_filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
|
|
@ -15,3 +15,4 @@ export * as OpenAICompatible from "./openai-compatible"
|
||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
export * as OpenRouter from "./openrouter"
|
export * as OpenRouter from "./openrouter"
|
||||||
export * as XAI from "./xai"
|
export * as XAI from "./xai"
|
||||||
|
export * as ZAI from "./zai"
|
||||||
|
|
|
||||||
35
packages/ai/src/providers/zai.ts
Normal file
35
packages/ai/src/providers/zai.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { ZAIImages } from "../protocols/zai-images"
|
||||||
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
|
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||||
|
|
||||||
|
export const id = ProviderID.make("zai")
|
||||||
|
|
||||||
|
export type Config = ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ZAIImageOptions } from "../protocols/zai-images"
|
||||||
|
|
||||||
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
ZAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
image,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = configure()
|
||||||
|
export const image = provider.image
|
||||||
28
packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"],
|
||||||
|
"name": "zai-images/generates-an-image",
|
||||||
|
"recordedAt": "2026-07-19T16:03:55.761Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.z.ai/api/paas/v4/images/generations",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json; charset=UTF-8"
|
||||||
|
},
|
||||||
|
"body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
type ImageRequestFor,
|
type ImageRequestFor,
|
||||||
type ImageRoute,
|
type ImageRoute,
|
||||||
} from "../src"
|
} from "../src"
|
||||||
import { Google, OpenAI, XAI } from "../src/providers"
|
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||||
|
|
||||||
type GoogleLikeOptions = {
|
type GoogleLikeOptions = {
|
||||||
readonly aspectRatio?: "1:1" | "16:9"
|
readonly aspectRatio?: "1:1" | "16:9"
|
||||||
|
|
@ -99,6 +99,20 @@ Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
|
||||||
// @ts-expect-error Known xAI string options retain their value kind.
|
// @ts-expect-error Known xAI string options retain their value kind.
|
||||||
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
||||||
|
|
||||||
|
const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
ZAI.configure({ image: { options: { quality: "hd" } } })
|
||||||
|
Image.generate({
|
||||||
|
model: zai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { quality: "future-quality", userID: "user-123", future_option: true },
|
||||||
|
})
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } })
|
||||||
|
// @ts-expect-error Known Z.ai string options retain their value kind.
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||||
|
// @ts-expect-error Known Z.ai user IDs retain their value kind.
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } })
|
||||||
|
|
||||||
declare const generic: ImageModel<ImageOptions>
|
declare const generic: ImageModel<ImageOptions>
|
||||||
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
||||||
|
|
||||||
|
|
|
||||||
32
packages/ai/test/provider/zai-images.recorded.test.ts
Normal file
32
packages/ai/test/provider/zai-images.recorded.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image } from "../../src"
|
||||||
|
import { ZAI } from "../../src/providers"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY ?? "fixture" }).image("cogview-4-250304")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "zai-images",
|
||||||
|
provider: "zai",
|
||||||
|
protocol: "zai-images",
|
||||||
|
requires: ["ZAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Z.ai Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat red circle centered on a plain white background.",
|
||||||
|
options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.image?.data).toBeString()
|
||||||
|
expect(response.image?.data).toStartWith("https://")
|
||||||
|
expect(response.providerMetadata?.zai).toBeDefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
130
packages/ai/test/provider/zai-images.test.ts
Normal file
130
packages/ai/test/provider/zai-images.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { Image, ImageClient } from "../../src"
|
||||||
|
import { ZAI } from "../../src/providers"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
|
|
||||||
|
describe("Z.ai Images", () => {
|
||||||
|
it.effect("generates through the Z.ai Images API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: ZAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.z.ai.test/api/paas/v4",
|
||||||
|
headers: { "x-default": "yes" },
|
||||||
|
http: { body: { configured: true, quality: "configured" }, query: { trace: "default" } },
|
||||||
|
}).image("glm-image"),
|
||||||
|
prompt: "A red circle on a white background",
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "alias-user",
|
||||||
|
user_id: "raw-user",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "request" },
|
||||||
|
body: { quality: "final", user_id: "final-user" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.image?.data).toBe("https://cdn.z.ai/generated.png")
|
||||||
|
expect(response.providerMetadata).toEqual({
|
||||||
|
zai: {
|
||||||
|
created: 1_760_335_349,
|
||||||
|
id: "generation-1",
|
||||||
|
requestID: "request-1",
|
||||||
|
contentFilter: [{ role: "future-role", level: 4.5 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.z.ai.test/api/paas/v4/images/generations?trace=request")
|
||||||
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "glm-image",
|
||||||
|
prompt: "A red circle on a white background",
|
||||||
|
quality: "final",
|
||||||
|
user_id: "final-user",
|
||||||
|
future_option: true,
|
||||||
|
configured: true,
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
created: 1_760_335_349,
|
||||||
|
id: "generation-1",
|
||||||
|
request_id: "request-1",
|
||||||
|
data: [{ url: "https://cdn.z.ai/generated.png" }],
|
||||||
|
content_filter: [{ role: "future-role", level: 4.5 }],
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lets raw native options override aliases", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "test",
|
||||||
|
options: { quality: "future-quality", userID: "x", user_id: "raw-user" },
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" })
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects invalid response structures", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = ZAI.configure({ apiKey: "test" }).image("model")
|
||||||
|
const payloads = [
|
||||||
|
{},
|
||||||
|
{ data: [] },
|
||||||
|
{ data: [{ b64_json: "image" }] },
|
||||||
|
{ data: [{ url: 1 }] },
|
||||||
|
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
yield* Effect.forEach(payloads, (payload) =>
|
||||||
|
Image.generate({ model, prompt: "test" }).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue