Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline de5600c77e fix(ai): preserve image protocol validation 2026-07-19 16:11:58 +00:00
Aiden Cline e3094eae1a fix(ai): support custom image auth 2026-07-19 16:07:53 +00:00
Aiden Cline 16e4625380 fix(ai): harden xAI image responses 2026-07-19 16:06:43 +00:00
Aiden Cline 3c0c1312e4 feat(ai): add xAI image generation 2026-07-19 16:06:43 +00:00
10 changed files with 390 additions and 54 deletions
+17 -2
View File
@@ -5,7 +5,7 @@ Schema-first AI primitives for opencode. Provider quirks live in adapters, not i
```ts
import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { OpenAI, XAI } from "@opencode-ai/ai/providers"
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
@@ -45,13 +45,28 @@ const program = Effect.gen(function* () {
})
```
xAI uses the same image domain with Grok Imagine-specific controls:
```ts
const program = Effect.gen(function* () {
return yield* Image.generate({
model: XAI.configure({
apiKey: process.env.XAI_API_KEY,
image: { providerOptions: { resolution: "1k", responseFormat: "b64_json" } },
}).image("grok-imagine-image"),
prompt: "A futuristic city skyline at sunset",
aspectRatio: "16:9",
})
})
```
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
```ts
const program = Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-5"),
prompt: "Design a solarpunk rooftop garden, then show me.",
tools: [OpenAI.imageGeneration({ quality: "high" })],
}),
+105 -42
View File
@@ -10,10 +10,9 @@ import {
} from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
import { ProviderShared } from "./shared"
import { ProviderShared, optionalNull } from "./shared"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-images"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/images/generations"
@@ -25,6 +24,13 @@ export interface OpenAIImageOptions {
readonly outputCompression?: number
}
export interface XAIImageOptions {
readonly resolution?: "1k" | "2k"
readonly responseFormat?: "url" | "b64_json"
}
export type ImageProtocol = "openai" | "xai"
const OpenAIImageBody = Schema.Struct({
model: Schema.String,
prompt: Schema.String,
@@ -35,44 +41,74 @@ const OpenAIImageBody = Schema.Struct({
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
aspect_ratio: Schema.optional(Schema.String),
resolution: Schema.optional(Schema.Literals(["1k", "2k"])),
response_format: Schema.optional(Schema.Literals(["url", "b64_json"])),
})
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
const OpenAIImageResponse = Schema.Struct({
const ImageResponseFields = {
data: Schema.Array(
Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
b64_json: optionalNull(Schema.String),
url: optionalNull(Schema.String),
revised_prompt: Schema.optional(Schema.String),
mime_type: optionalNull(Schema.String),
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
),
}
const OpenAIImageUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
const OpenAIImageResponse = Schema.Struct({
...ImageResponseFields,
usage: Schema.optional(OpenAIImageUsage),
})
const XAIImageResponse = Schema.Struct({
...ImageResponseFields,
usage: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
export interface ModelInput {
readonly id: string
readonly protocol?: ImageProtocol
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly defaults?: ImageModelDefaults
}
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
const openAIOptions = (request: ImageRequest): OpenAIImageOptions => ({
...request.model.defaults?.providerOptions?.openai,
...request.providerOptions?.openai,
})
const body = (request: ImageRequest): OpenAIImageBody => {
const options = providerOptions(request)
const xaiOptions = (request: ImageRequest): XAIImageOptions => ({
...request.model.defaults?.providerOptions?.xai,
...request.providerOptions?.xai,
})
const body = (request: ImageRequest, protocol: ImageProtocol): OpenAIImageBody => {
if (protocol === "xai") {
const options = xaiOptions(request)
return {
model: request.model.id,
prompt: request.prompt,
n: request.count,
aspect_ratio: request.aspectRatio,
resolution: options.resolution,
response_format: options.responseFormat,
}
}
const options = openAIOptions(request)
return {
model: request.model.id,
prompt: request.prompt,
@@ -86,11 +122,11 @@ const body = (request: ImageRequest): OpenAIImageBody => {
}
}
const invalidOutput = (message: string) =>
const invalidOutput = (adapter: string, message: string) =>
new LLMError({
module: ADAPTER,
module: adapter,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
reason: new InvalidProviderOutputReason({ message, route: adapter }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -110,6 +146,9 @@ const PROTOCOL_BODY_FIELDS = new Set([
"moderation",
"output_format",
"output_compression",
"aspect_ratio",
"resolution",
"response_format",
])
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
@@ -126,15 +165,23 @@ const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
})
export const model = (input: ModelInput) => {
const protocol = input.protocol ?? "openai"
const adapter = `${protocol}-images`
const route: ImageRoute = {
id: ADAPTER,
id: adapter,
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
if (request.aspectRatio !== undefined)
if (protocol === "openai" && request.aspectRatio !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
if (protocol === "xai" && request.size !== undefined)
return yield* ProviderShared.invalidRequest("xAI Images does not support the common size option")
if (request.seed !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
return yield* ProviderShared.invalidRequest(
`${protocol === "openai" ? "OpenAI" : "xAI"} Images does not support the common seed option`,
)
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(
body(request, protocol),
)
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
const text = ProviderShared.encodeJson(overlaidBody)
@@ -153,54 +200,70 @@ export const model = (input: ModelInput) => {
),
)
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
Effect.mapError(() => invalidOutput(adapter, `Failed to read the ${protocol} Images response`)),
)
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
const responseSchema = protocol === "openai" ? OpenAIImageResponse : XAIImageResponse
const decoded = yield* Schema.decodeUnknownEffect(responseSchema)(payload).pipe(
Effect.mapError(() => invalidOutput(adapter, `${protocol} Images returned an invalid response`)),
)
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
const format = decoded.output_format ?? openAIOptions(request).outputFormat ?? "png"
const images = yield* Effect.forEach(decoded.data, (item, index) => {
const mediaType = item.mime_type ?? (protocol === "openai" ? `image/${format}` : "application/octet-stream")
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
Effect.mapError(() =>
invalidOutput(adapter, `${protocol} Images result ${index} contains invalid base64 data`),
),
Effect.map(
(data) =>
new GeneratedImage({
mediaType: `image/${format}`,
mediaType,
data,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
item.revised_prompt === undefined
? undefined
: { [protocol]: { revisedPrompt: item.revised_prompt } },
}),
),
)
if (item.url)
return Effect.succeed(
new GeneratedImage({
mediaType: `image/${format}`,
mediaType,
data: item.url,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
item.revised_prompt === undefined ? undefined : { [protocol]: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
return Effect.fail(
invalidOutput(adapter, `${protocol} Images result ${index} has neither image data nor a URL`),
)
})
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
if (images.length === 0) return yield* invalidOutput(adapter, `${protocol} Images returned no images`)
const openAIUsage =
protocol === "openai" && Schema.is(OpenAIImageUsage)(decoded.usage) ? decoded.usage : undefined
const xaiUsage = protocol === "xai" && ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
return new ImageResponse({
images,
usage:
decoded.usage === undefined
openAIUsage === undefined && xaiUsage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
outputTokens: decoded.usage.output_tokens,
totalTokens: decoded.usage.total_tokens,
providerMetadata: { openai: decoded.usage },
inputTokens: openAIUsage?.input_tokens,
outputTokens: openAIUsage?.output_tokens,
totalTokens: openAIUsage?.total_tokens,
providerMetadata: { [protocol]: openAIUsage ?? xaiUsage ?? {} },
}),
providerMetadata: { openai: { outputFormat: format } },
providerMetadata: {
[protocol]: {
...(protocol === "openai" ? { outputFormat: format } : {}),
...(protocol === "xai" && decoded.usage !== undefined ? { usage: decoded.usage } : {}),
},
},
})
}),
}
return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
return ImageModel.make({ id: input.id, provider: protocol, route, defaults: input.defaults })
}
export const OpenAIImages = {
+26 -3
View File
@@ -1,23 +1,31 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { OpenAIImages, type XAIImageOptions } from "../protocols/openai-images"
export const id = ProviderID.make("xai")
export type ModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly image?: ImageConfig
}
export interface ImageConfig {
readonly providerOptions?: XAIImageOptions
}
export type { XAIImageOptions } from "../protocols/openai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
const { apiKey: _, auth: _auth, baseURL, image: _image, ...rest } = input
return OpenAIResponses.route.with({
...rest,
provider: id,
@@ -27,7 +35,7 @@ const configuredResponsesRoute = (input: ModelOptions) => {
}
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
const { apiKey: _, auth: _auth, baseURL, image: _image, ...rest } = input
return OpenAICompatibleChat.route.with({
...rest,
provider: id,
@@ -41,11 +49,25 @@ export const configure = (input: ModelOptions = {}) => {
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
protocol: "xai",
auth: auth(input),
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
headers: input.headers,
defaults: {
providerOptions:
input.image?.providerOptions === undefined ? undefined : { xai: { ...input.image.providerOptions } },
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
},
})
return {
id,
model: responses,
responses,
chat,
image,
configure,
}
}
@@ -54,3 +76,4 @@ export const provider = configure()
export const model = provider.model
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+24 -5
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, LLMRequest, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -14,7 +14,7 @@ export type CredentialError = MissingCredentialError | Config.ConfigError
export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
export interface RequestAuthInput {
readonly request: { readonly http?: HttpOptions }
readonly method: "POST" | "GET"
readonly url: string
@@ -22,6 +22,10 @@ export interface AuthInput {
readonly headers: Headers.Headers
}
export interface AuthInput extends Omit<RequestAuthInput, "request"> {
readonly request: LLMRequest
}
export interface Credential {
readonly load: Effect.Effect<Redacted.Redacted, CredentialError>
readonly orElse: (that: Credential) => Credential
@@ -31,7 +35,7 @@ export interface Credential {
}
export interface Definition {
readonly apply: (input: AuthInput) => Effect.Effect<Headers.Headers, AuthError>
readonly apply: (input: RequestAuthInput) => Effect.Effect<Headers.Headers, AuthError>
readonly andThen: (that: Definition) => Definition
readonly orElse: (that: Definition) => Definition
readonly pipe: <A>(f: (self: Definition) => A) => A
@@ -100,7 +104,22 @@ export const headers = (input: Headers.Input) =>
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply)
export const customRequest = (apply: (input: RequestAuthInput) => Effect.Effect<Headers.Headers, LLMError>) =>
auth(apply)
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) =>
auth((input) => {
if (input.request instanceof LLMRequest) return apply({ ...input, request: input.request })
return Effect.fail(
new LLMError({
module: "Auth",
method: "custom",
reason: new InvalidRequestReason({
message: "Auth.custom only supports LLM requests; use Auth.customRequest for other request types",
}),
}),
)
})
export const passthrough = none
@@ -150,7 +169,7 @@ const toLLMError = (error: AuthError): LLMError => {
export const toEffect =
(input: Definition) =>
(authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> =>
(authInput: RequestAuthInput): Effect.Effect<Headers.Headers, LLMError> =>
input.apply(authInput).pipe(Effect.mapError(toLLMError))
export * as Auth from "./auth"
+1 -1
View File
@@ -17,7 +17,7 @@ export { Framing } from "./framing"
export { Protocol } from "./protocol"
export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport"
export * as Transport from "./transport"
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth"
export type { Definition as AuthShape, AuthInput, RequestAuthInput, Credential, CredentialError } from "./auth"
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options"
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
+8 -1
View File
@@ -1,4 +1,4 @@
import { Config } from "effect"
import { Config, Effect } from "effect"
import type { Auth } from "../src/route/auth"
import type { ModelFactory } from "../src/route/auth-options"
import { Auth as RuntimeAuth } from "../src/route/auth"
@@ -33,6 +33,13 @@ declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
const configApiKey = Config.redacted("OPENAI_API_KEY")
RuntimeAuth.custom((input) => {
input.request.model
input.request.metadata
input.request.providerOptions
return Effect.succeed(input.headers)
})
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
// @ts-expect-error route model selection does not configure endpoints.
File diff suppressed because one or more lines are too long
+27
View File
@@ -92,4 +92,31 @@ describe("Image", () => {
),
),
)
it.effect("rejects malformed OpenAI image usage", () =>
Image.generate({
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
}).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }], usage: "invalid" }), {
headers: { "content-type": "application/json" },
}),
),
),
),
),
),
),
)
})
@@ -0,0 +1,34 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src"
import { XAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const model = XAI.configure({
apiKey: process.env.XAI_API_KEY ?? "fixture",
image: { providerOptions: { resolution: "1k", responseFormat: "b64_json" } },
}).image("grok-imagine-image")
const recorded = recordedTests({
prefix: "xai-images",
provider: "xai",
protocol: "xai-images",
requires: ["XAI_API_KEY"],
})
describe("xAI Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat black diamond centered on a plain white background.",
aspectRatio: "1:1",
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType.startsWith("image/")).toBe(true)
expect(response.image?.data).toBeInstanceOf(Uint8Array)
expect(response.image?.data.length).toBeGreaterThan(0)
}),
)
})
@@ -0,0 +1,116 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../../src"
import { XAI } from "../../src/providers"
import { Auth } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
describe("xAI Images", () => {
it.effect("generates through the OpenAI-compatible Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: XAI.configure({
apiKey: "test",
baseURL: "https://api.xai.test/v1",
image: { providerOptions: { resolution: "1k", responseFormat: "b64_json" } },
}).image("grok-imagine-image"),
prompt: "A robot tending a rooftop garden",
count: 2,
aspectRatio: "16:9",
})
expect(response.images).toHaveLength(2)
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.images[1]?.mediaType).toBe("application/octet-stream")
expect(response.images[1]?.data).toBe("https://api.xai.test/image.jpg")
expect(response.usage?.providerMetadata).toEqual({ xai: { num_images: 2 } })
expect(response.providerMetadata).toEqual({ xai: { usage: { num_images: 2 } } })
}).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.xai.test/v1/images/generations")
expect(request.headers.get("authorization")).toBe("Bearer test")
expect(JSON.parse(input.text)).toEqual({
model: "grok-imagine-image",
prompt: "A robot tending a rooftop garden",
n: 2,
aspect_ratio: "16:9",
resolution: "1k",
response_format: "b64_json",
})
return input.respond(
JSON.stringify({
data: [
{ b64_json: "AQID", url: null, mime_type: "image/jpeg" },
{ b64_json: null, url: "https://api.xai.test/image.jpg", mime_type: null },
],
usage: { num_images: 2 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("supports request-level custom auth", () =>
Image.generate({
model: XAI.configure({
baseURL: "https://api.xai.test/v1",
auth: Auth.customRequest((input) =>
Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)),
),
}).image("grok-imagine-image"),
prompt: "A robot tending a rooftop garden",
}).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.headers.get("x-custom-auth")).toBe("api.xai.test")
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
headers: { "content-type": "application/json" },
})
}),
),
),
),
),
),
)
it.effect("rejects LLM-only custom auth for image requests", () =>
Image.generate({
model: XAI.configure({
baseURL: "https://api.xai.test/v1",
auth: Auth.custom(() => Effect.die("LLM custom auth should not receive an image request")),
}).image("grok-imagine-image"),
prompt: "A robot tending a rooftop garden",
}).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("Auth.customRequest")
}),
),
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(dynamicResponse(() => Effect.die("invalid auth should not reach the provider"))),
),
),
),
)
})