feat(ai): add xAI image generation

This commit is contained in:
Aiden Cline
2026-07-19 15:58:10 +00:00
committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
parent fe9a936867
commit 3c0c1312e4
6 changed files with 257 additions and 41 deletions
+16 -1
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,6 +45,21 @@ 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
+88 -37
View File
@@ -13,7 +13,6 @@ import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJs
import { ProviderShared } 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,6 +41,9 @@ 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>
@@ -44,35 +53,53 @@ const OpenAIImageResponse = Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
revised_prompt: Schema.optional(Schema.String),
mime_type: Schema.optional(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)),
}),
),
usage: Schema.optional(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)),
})
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 +113,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 +137,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 +156,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 +191,67 @@ 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")),
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 ?? `image/${format}`
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 usage = protocol === "openai" && Schema.is(OpenAIImageUsage)(decoded.usage) ? decoded.usage : undefined
return new ImageResponse({
images,
usage:
decoded.usage === undefined
usage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
outputTokens: decoded.usage.output_tokens,
totalTokens: decoded.usage.total_tokens,
providerMetadata: { openai: decoded.usage },
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
totalTokens: usage.total_tokens,
providerMetadata: { [protocol]: usage },
}),
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
File diff suppressed because one or more lines are too long
@@ -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,61 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../../src"
import { XAI } from "../../src/providers"
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.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", mime_type: "image/jpeg" },
{ b64_json: "BAUG", mime_type: "image/jpeg" },
],
usage: { num_images: 2 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
})