Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
760fe11
Add Foundation Models 27 prompt attachments
qoli Oct 9, 2026
379ee3c
Fix attachment prompt replay
qoli Oct 11, 2026
2d1a32b
Preserve attachments in tool outputs
qoli Oct 11, 2026
dc79bbf
Document attachments in tool outputs
qoli Oct 11, 2026
f96d6de
Generate fresh prompt segment identifiers
qoli Oct 12, 2026
c7aaa57
Preserve Gemini tool output segments
qoli Oct 12, 2026
d70bf12
Generate fresh prompt segment identifiers
qoli Oct 12, 2026
ddfa8f1
Preserve Gemini tool output segments
qoli Oct 12, 2026
d9de68a
Avoid relowering recorded prompt attachments
qoli Oct 13, 2026
9ab55ea
Clarify supported attachment platforms
qoli Oct 13, 2026
a03fe05
Avoid relowering recorded prompt attachments
qoli Oct 13, 2026
51fa0a9
Clarify supported attachment platforms
qoli Oct 13, 2026
f57be8c
Merge upstream main into tool output attachments
qoli Oct 14, 2026
810861f
Merge updated attachment support into documentation
qoli Oct 14, 2026
1892a39
Inline Gemini tool output file attachments
qoli Oct 15, 2026
8c9e8d9
Merge branch 'codex/upstream-tool-output-attachments' into codex/upst…
qoli Oct 15, 2026
2f435bd
Format Gemini attachment MIME mapping
qoli Oct 16, 2026
446f111
Merge branch 'codex/upstream-tool-output-attachments' into codex/upst…
qoli Oct 16, 2026
4d9cd91
Inline local Anthropic image attachments during transport
qoli Oct 17, 2026
6354a86
Merge branch 'codex/upstream-tool-output-attachments' into codex/upst…
qoli Oct 17, 2026
2eb4abd
Merge remote-tracking branch 'upstream/main' into codex/upstream-tool…
qoli Oct 17, 2026
86d6952
Merge branch 'codex/upstream-attachment-docs' into codex/upstream-too…
qoli Oct 17, 2026
5a17ad3
Snapshot local tool image data before persisting output
qoli Oct 17, 2026
23a5a7f
Merge upstream native delegate and request options into Tool attachments
qoli Oct 19, 2026
cf756b1
Preserve native Tool attachment snapshots through delegate execution
qoli Oct 19, 2026
b6d5ee5
Merge remote-tracking branch 'upstream/main' into codex/upstream-tool…
qoli Oct 20, 2026
c96bd09
Reject non-image file types when snapshotting Tool attachments
qoli Oct 20, 2026
07ad954
Describe remote Gemini Tool images as URL text
qoli Oct 20, 2026
14d1b74
Remove unreachable remote-URL failure from Gemini file lowering
qoli Oct 21, 2026
77e57d0
Use image placeholders for unsupported provider tool-output formats
qoli Oct 21, 2026
aed9f2a
Merge commit '7013d51f5f5249ab907ac8e2b5b5d0eb222c4153' into codex/up…
qoli Oct 22, 2026
697ed57
Merge upstream 1.1 request accounting and replay updates
qoli Oct 23, 2026
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
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,31 @@ pass the model's multimodal projector with `mmprojPath:`.
> Image inputs are an AnyLanguageModel extension.
> See [Differences from Foundation Models](#differences-from-foundation-models).

### Prompt attachments

Image attachments use the Foundation Models 27 prompt construction syntax:

```swift
let prompt = Prompt {
"Describe this image"
Attachment(imageURL: fileURL)
}
```

On Apple platforms, `Attachment(CGImage, orientation:)` retains typed image
content and orientation. Prompt builders preserve the order of text and images,
and the OS 26 compatibility transcript continues to store images using its
existing `Transcript.Segment.image` Codable representation.

`SystemLanguageModel` and `FoundationLanguageModel` bridge prompt attachments
to Foundation Models on OS 27. Earlier system runtimes reject them explicitly.
The system bridge requires local file URLs; other provider adapters retain URL
images in their existing transcript representation.

This first compatibility surface covers prompt `CGImage` and `imageURL`
attachments. Tool outputs, instruction attachments, labels, `CIImage`, pixel
buffers, and newer data attachments remain outside this change.
Comment thread
qoli marked this conversation as resolved.
Outdated

### Tool Calling

Tool calling is supported by all providers.
Expand Down Expand Up @@ -611,8 +636,8 @@ say which API they follow.
described in that provider's section under [Providers](#providers).
- `respond(to:images:)`, `Transcript.ImageSegment`, and `Transcript.Segment.image`:
[image inputs](#image-inputs).
Foundation Models 27 adds prompt attachments,
and AnyLanguageModel 2.0 will change to match them.
Foundation Models 27-style [prompt attachments](#prompt-attachments) are also
available for ordered text and image prompt construction.
- `ToolExecutionDelegate`, `ToolExecutionDecision`, and `toolExecutionDelegate`:
[observing and controlling tool calls](#tool-calling).
- `transcriptErrorHandlingPolicy` and `waitForResponseCompletion()`:
Expand Down Expand Up @@ -645,7 +670,7 @@ say which API they follow.
### Behavior differences

- A session adds the prompt to its transcript before the model responds,
as a single text segment.
preserving its text and image segment order.
- A structured response is recorded in the transcript as JSON text,
not as a structured segment.
- A session created from a transcript restores `instructions`
Expand Down
107 changes: 107 additions & 0 deletions Sources/AnyLanguageModel/Attachment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation

#if canImport(CoreGraphics) && canImport(ImageIO)
import CoreGraphics
import ImageIO
#endif

/// A typed prompt attachment, following Foundation Models 27's image surface.
///
/// AnyLanguageModel lowers image attachments into its existing compatibility
/// transcript image segments on deployment targets before OS 27.
public struct Attachment<Content> {
let content: Content
}

/// Image content retained until a prompt is lowered into the compatibility transcript.
public struct ImageAttachmentContent: Sendable, Equatable {
enum Source: Sendable {
case url(URL, orientation: UInt32?)
#if canImport(CoreGraphics) && canImport(ImageIO)
case image(CGImage, orientation: CGImagePropertyOrientation?)
#endif
}

let source: Source

public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs.source, rhs.source) {
case (.url(let a, let ao), .url(let b, let bo)):
return a == b && ao == bo
#if canImport(CoreGraphics) && canImport(ImageIO)
case (.image(let a, let ao), .image(let b, let bo)):
return a === b && ao == bo
#endif
default:
return false
}
}

func makeSegment(id: String) throws -> Transcript.ImageSegment {
switch source {
case .url(let url, let orientation):
#if canImport(CoreGraphics) && canImport(ImageIO)
if let orientation {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil)
else {
throw Transcript.ImageEncodingError.imageConversionFailed
}
return try Self(
source: .image(
image,
orientation: CGImagePropertyOrientation(rawValue: orientation)
)
).makeSegment(id: id)
}
#endif
return .init(id: id, url: url)
#if canImport(CoreGraphics) && canImport(ImageIO)
case .image(let image, let orientation):
let bytes = NSMutableData()
guard
let destination = CGImageDestinationCreateWithData(
bytes,
"public.png" as CFString,
1,
nil
)
else {
throw Transcript.ImageEncodingError.imageConversionFailed
}
var properties: [CFString: Any] = [:]
if let orientation {
properties[kCGImagePropertyOrientation] = orientation.rawValue
}
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
throw Transcript.ImageEncodingError.imageConversionFailed
}
return .init(id: id, data: bytes as Data, mimeType: "image/png")
#endif
}
}
}

extension Attachment where Content == ImageAttachmentContent {
#if canImport(CoreGraphics) && canImport(ImageIO)
public init(_ image: CGImage, orientation: CGImagePropertyOrientation? = nil) {
content = .init(source: .image(image, orientation: orientation))
}

public init(imageURL: URL, orientation: CGImagePropertyOrientation? = nil) {
// Preserve the URL itself; its bytes are not read merely by constructing a prompt.
content = .init(source: .url(imageURL, orientation: orientation?.rawValue))
}
#else
public init(imageURL: URL) {
content = .init(source: .url(imageURL, orientation: nil))
}
#endif
}

extension Attachment: PromptRepresentable where Content == ImageAttachmentContent {
public var promptRepresentation: Prompt {
Prompt(components: [.image(id: UUID().uuidString, content: content)])
}
}
18 changes: 15 additions & 3 deletions Sources/AnyLanguageModel/LanguageModelSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ public final class LanguageModelSession: @unchecked Sendable {
let promptEntry = Transcript.Entry.prompt(
Transcript.Prompt(
id: promptID,
segments: [.text(.init(content: prompt.description))],
segments: try prompt.makeTranscriptSegments(),
options: options,
responseFormat: responseFormat
)
Expand Down Expand Up @@ -776,9 +776,15 @@ public final class LanguageModelSession: @unchecked Sendable {
options: GenerationOptions = GenerationOptions()
) -> sending ResponseStream<Content> where Content: Generable {
// Add prompt to transcript
let segments: [Transcript.Segment]
do {
segments = try prompt.makeTranscriptSegments()
} catch {
return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) })
}
let promptEntry = Transcript.Entry.prompt(
Transcript.Prompt(
segments: [.text(.init(content: prompt.description))],
segments: segments,
options: options,
responseFormat: type == String.self ? nil : .init(type: type)
)
Expand Down Expand Up @@ -924,9 +930,15 @@ extension LanguageModelSession {
includeSchemaInPrompt: Bool = true,
options: GenerationOptions = GenerationOptions()
) -> sending ResponseStream<GeneratedContent> {
let segments: [Transcript.Segment]
do {
segments = try prompt.makeTranscriptSegments()
} catch {
return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) })
}
let promptEntry = Transcript.Entry.prompt(
Transcript.Prompt(
segments: [.text(.init(content: prompt.description))],
segments: segments,
options: options,
responseFormat: .init(schema: schema)
)
Expand Down
10 changes: 8 additions & 2 deletions Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
return try await fmRespond(
makeSession: { try await self.makeSession(for: session, prompt: prompt) },
fmPrompt: prompt.toFoundationModels(),
fmPrompt: try prompt.toFoundationModels(),
fmOptions: options.toFoundationModels(),
type: type,
schema: schema,
Expand Down Expand Up @@ -209,9 +209,15 @@
includeSchemaInPrompt: Bool,
options: GenerationOptions
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
let fmPrompt: FoundationModels.Prompt
do {
fmPrompt = try prompt.toFoundationModels()
} catch {
return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) })
}
return fmStreamResponse(
makeSession: { try await self.makeSession(for: session, prompt: prompt) },
fmPrompt: prompt.toFoundationModels(),
fmPrompt: fmPrompt,
fmOptions: options.toFoundationModels(),
type: type,
schema: schema,
Expand Down
74 changes: 62 additions & 12 deletions Sources/AnyLanguageModel/Models/SystemLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
includeSchemaInPrompt: Bool,
options: GenerationOptions
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
let fmPrompt = prompt.toFoundationModels()
let fmPrompt = try prompt.toFoundationModels()
let fmOptions = options.toFoundationModels()

return try await fmRespond(
Expand Down Expand Up @@ -249,7 +249,12 @@
includeSchemaInPrompt: Bool,
options: GenerationOptions
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
let fmPrompt = prompt.toFoundationModels()
let fmPrompt: FoundationModels.Prompt
do {
fmPrompt = try prompt.toFoundationModels()
} catch {
return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) })
}
let fmOptions = options.toFoundationModels()

return fmStreamResponse(
Expand Down Expand Up @@ -336,11 +341,9 @@
guard let lastEntry = transcript.last, case .prompt(let lastPrompt) = lastEntry else {
return transcript
}
let lastText = lastPrompt.segments.compactMap { segment -> String? in
if case .text(let textSegment) = segment { return textSegment.content }
return nil
}.joined()
guard lastText == prompt.description else {
guard let promptSegments = try? prompt.makeTranscriptSegments(),
lastPrompt.segments == promptSegments
else {
return transcript
}
return Transcript(entries: transcript.dropLast())
Expand Down Expand Up @@ -430,8 +433,42 @@

@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
extension Prompt {
func toFoundationModels() -> FoundationModels.Prompt {
FoundationModels.Prompt(self.description)
func toFoundationModels() throws -> FoundationModels.Prompt {
let prompts: [FoundationModels.Prompt] = try components.map { component in
switch component {
case .text(let text):
return FoundationModels.Prompt(text.content)
case .image(_, let image):
#if compiler(>=6.4) && !os(tvOS) && !os(watchOS)
if #available(macOS 27, iOS 27, visionOS 27, *) {
switch image.source {
case .url(let url, let orientation):
guard url.isFileURL else {
throw PromptBridgeError.nonFileImageURL
}
return FoundationModels.Prompt(
FoundationModels.Attachment(
imageURL: url,
orientation: orientation.flatMap(
CGImagePropertyOrientation.init(rawValue:)
)
)
)
case .image(let value, let orientation):
return FoundationModels.Prompt(
FoundationModels.Attachment(value, orientation: orientation)
)
}
}
#endif
throw PromptBridgeError.attachmentsUnavailable
}
}
return FoundationModels.Prompt {
for prompt in prompts {
prompt
}
}
}
}

Expand Down Expand Up @@ -517,11 +554,16 @@
}
}

enum PromptBridgeError: Error {
case attachmentsUnavailable
case nonFileImageURL
}
Comment thread
qoli marked this conversation as resolved.
Outdated

/// A type-erased wrapper that bridges any `Tool` to `FoundationModels.Tool`.
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
private struct AnyToolWrapper: FoundationModels.Tool {
typealias Arguments = FoundationModels.GeneratedContent
typealias Output = String
typealias Output = FoundationModels.Prompt

let name: String
let description: String
Expand All @@ -540,7 +582,7 @@

func call(arguments: FoundationModels.GeneratedContent) async throws -> Output {
let output = try await wrappedTool.callFunction(arguments: arguments)
return output.promptRepresentation.description
return try output.promptRepresentation.toFoundationModels()
}
}

Expand Down Expand Up @@ -924,7 +966,15 @@
else {
return nil
}
self.init(cgImage)
let properties =
CGImageSourceCopyPropertiesAtIndex(
imageSource,
0,
nil
) as? [CFString: Any]
let orientation = (properties?[kCGImagePropertyOrientation] as? NSNumber)
.flatMap { CGImagePropertyOrientation(rawValue: $0.uint32Value) }
self.init(cgImage, orientation: orientation)
}
}
}
Expand Down
Loading
Loading