Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
Use `Image.generate` with an image model for direct asset generation:

```ts
import { Image } from "@opencode-ai/ai"
import { Image, ImageInput } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"

const program = Effect.gen(function* () {
Expand All @@ -49,6 +49,48 @@ const program = Effect.gen(function* () {
})
```

Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:

```ts
const response =
yield *
Image.generate({
model,
prompt: "Combine these product photos into one studio scene",
images: [
ImageInput.bytes(firstBytes, "image/png"),
ImageInput.url("https://example.com/second.webp"),
ImageInput.file("file_123"),
],
options,
http,
})
```

`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
`ImageInput` for inpainting:

```ts
yield *
Image.generate({
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
prompt,
images: [ImageInput.bytes(sourceBytes, "image/png")],
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
})
```

The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
`InvalidRequest` before network I/O.

Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:

```ts
Expand Down
35 changes: 35 additions & 0 deletions packages/ai/src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,44 @@ export const ImageModelSchema = Schema.declare((value): value is ImageModel => v
expected: "Image.Model",
})

const ImageBytesInput = Schema.Struct({
type: Schema.Literal("bytes"),
data: Schema.Uint8Array,
mediaType: Schema.String,
})
const ImageUrlInput = Schema.Struct({
type: Schema.Literal("url"),
url: Schema.String,
})
const ImageFileIDInput = Schema.Struct({
type: Schema.Literal("file-id"),
id: Schema.String,
})
const ImageFileURIInput = Schema.Struct({
type: Schema.Literal("file-uri"),
uri: Schema.String,
mediaType: Schema.String,
})

export const ImageInputSchema = Schema.Union([
ImageBytesInput,
ImageUrlInput,
ImageFileIDInput,
ImageFileURIInput,
]).pipe(Schema.toTaggedUnion("type"))
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>

export const ImageInput = {
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
url: (url: string): ImageInput => ({ type: "url", url }),
file: (id: string): ImageInput => ({ type: "file-id", id }),
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
} as const

export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
model: ImageModelSchema,
prompt: Schema.String,
images: Schema.optional(Schema.Array(ImageInputSchema)),
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
http: Schema.optional(HttpOptions),
}) {
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type {
Service as LLMClientService,
} from "./route/client"
export * from "./schema"
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse } from "./image"
export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image"
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
export { Image } from "./image"
export { Tool, ToolFailure, toDefinitions } from "./tool"
Expand Down
55 changes: 43 additions & 12 deletions packages/ai/src/protocols/google-images.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
import {
GeneratedImage,
ImageModel,
ImageResponse,
type ImageInput,
type ImageRequestFor,
type ImageRoute,
} from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
Expand All @@ -12,6 +19,7 @@ import {
type ProviderMetadata,
} from "../schema"
import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input"

const ADAPTER = "google-images"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
Expand All @@ -31,7 +39,7 @@ export type GoogleImageOptions = {
export type GoogleImageBody = Record<string, unknown> & {
readonly contents: ReadonlyArray<{
readonly role: "user"
readonly parts: ReadonlyArray<{ readonly text: string }>
readonly parts: ReadonlyArray<Record<string, unknown>>
}>
readonly generationConfig: Record<string, unknown>
}
Expand Down Expand Up @@ -116,15 +124,6 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
)
}

const body = (request: ImageRequestFor<GoogleImageOptions>, overlay: Record<string, unknown> | undefined) =>
mergeJsonRecords(
{
contents: [{ role: "user", parts: [{ text: request.prompt }] }],
generationConfig: nativeOptions(request.options),
},
overlay,
) as GoogleImageBody

const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new LLMError({
module: ADAPTER,
Expand All @@ -143,8 +142,16 @@ export const model = (input: ModelInput) => {
const route: ImageRoute<GoogleImageOptions> = {
id: ADAPTER,
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
const http = mergeHttpOptions(request.model.http, request.http)
const text = ProviderShared.encodeJson(body(request, http?.body))
const requestBody = mergeJsonRecords(
{
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
generationConfig: nativeOptions(request.options),
},
http?.body,
) as GoogleImageBody
const text = ProviderShared.encodeJson(requestBody)
const url = applyQuery(
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
http?.query,
Expand Down Expand Up @@ -278,6 +285,30 @@ export const model = (input: ModelInput) => {
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
}

const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
if (image.type === "bytes")
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
if (image.type === "url")
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
Effect.flatMap((decoded) => {
if (decoded === undefined)
return Effect.fail(
ImageInputs.invalid(
ADAPTER,
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
),
)
return Effect.succeed({
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
})
}),
)
return Effect.fail(
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
)
}

export const GoogleImages = {
model,
} as const
Loading
Loading