Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
f2de319
Add Foundation Models 27 request options
qoli Oct 9, 2026
cd1d6fc
Add session properties to dynamic instructions
qoli Oct 11, 2026
9481380
Add session properties to dynamic instructions
qoli Oct 11, 2026
a03fd6b
Add Foundation Models 27 dynamic profiles
qoli Oct 11, 2026
8cc3a74
Preserve dynamic profiles across tool continuations
qoli Oct 11, 2026
da63029
Keep session property requests observable
qoli Oct 12, 2026
8b3a234
Keep session property requests observable
qoli Oct 12, 2026
9525809
Keep session property requests observable
qoli Oct 12, 2026
d0cae54
Isolate dynamic profile request state
qoli Oct 13, 2026
1ac8207
Isolate dynamic profile request state
qoli Oct 13, 2026
c350013
Fix dynamic profile continuation boundaries
qoli Oct 13, 2026
d1c8413
Merge remote-tracking branch 'upstream/main' into codex/upstream-dyna…
qoli Oct 14, 2026
49f681f
Merge updated dynamic profiles into tool continuations
qoli Oct 14, 2026
93e4d4d
Fix session property observation and typed defaults
qoli Oct 15, 2026
e99341e
Merge branch 'codex/upstream-session-properties' into codex/upstream-…
qoli Oct 15, 2026
cdeed51
Fix dynamic profile callback concurrency
qoli Oct 15, 2026
8e218df
Merge reviewed profile fixes into tool continuations
qoli Oct 15, 2026
d211109
Allow sampling mode without tool calling policy
qoli Oct 16, 2026
3ad28fb
Keep session history bindings isolated and instruction free
qoli Oct 16, 2026
1fde50e
Merge branch 'codex/upstream-request-options' into codex/upstream-dyn…
qoli Oct 16, 2026
05f892a
Merge branch 'codex/upstream-session-properties' into codex/upstream-…
qoli Oct 16, 2026
9b76d74
Preserve profile history and request callback identity
qoli Oct 16, 2026
ea464ed
Preserve pending profile history across model handoffs
qoli Oct 16, 2026
2c5c488
Invalidate stale history commits and evaluate property defaults outsi…
qoli Oct 17, 2026
905194e
Merge branch 'codex/upstream-session-properties' into codex/upstream-…
qoli Oct 17, 2026
d9e5f31
Preserve profile lifecycle identity and evolving callback history
qoli Oct 17, 2026
ea7de61
Merge branch 'codex/upstream-dynamic-profiles' into codex/upstream-pr…
qoli Oct 17, 2026
ef80f14
Carry callback history edits and original schema options through tool…
qoli Oct 17, 2026
790ce15
Merge remote-tracking branch 'upstream/main' into codex/upstream-sess…
qoli Oct 17, 2026
0a3580b
Match HistoryView collection shape and bind session properties in con…
qoli Oct 17, 2026
4cf0f06
Reshape dynamic profiles as composition-only API on current session p…
qoli Oct 17, 2026
c40cf05
Separate shared profile execution SPI from provider Tool loops
qoli Oct 17, 2026
455a415
Integrate OpenResponses profile Tool continuations
qoli Oct 17, 2026
0dad115
Merge remote-tracking branch 'upstream/main' into codex/upstream-sess…
qoli Oct 19, 2026
25d51f1
Preserve public request context and expose Tool session properties
qoli Oct 19, 2026
8e10048
Merge session properties and upstream request options into profiles
qoli Oct 19, 2026
982cc07
Match the SDK history transform closure contract
qoli Oct 19, 2026
31764ea
Merge profile API updates and upstream tool-round options
qoli Oct 19, 2026
89c0077
Use request options for shared profile tool-round budgets
qoli Oct 19, 2026
fcb08cf
Merge remote-tracking branch 'upstream/main' into codex/upstream-prof…
qoli Oct 20, 2026
90c85f1
Scope continuation entries to Tool rounds and share wire replay
qoli Oct 20, 2026
5e233be
Merge shared continuation runtime and current upstream into OpenRespo…
qoli Oct 20, 2026
192a8cd
Share OpenResponses replay while retaining ordinary in-flight messages
qoli Oct 20, 2026
1dcc38c
Let provider adapters retain their offered Tool boundary
qoli Oct 20, 2026
db6bc62
Preserve public Tool identity and bind at execution boundaries
qoli Oct 21, 2026
9b01280
Merge main while retaining offered Tool filtering and execution binding
qoli Oct 21, 2026
846508e
Merge branch 'codex/upstream-session-properties' into codex/upstream-…
qoli Oct 21, 2026
b05f1fa
Merge Tool identity compatibility into shared profile runtime
qoli Oct 21, 2026
57094db
Share local-provider round budgets and verify Tool-call entry identity
qoli Oct 21, 2026
ce17d35
Merge current runtime and preserve producing OpenResponses Tool-call …
qoli Oct 21, 2026
170aa66
Merge commit '7013d51f5f5249ab907ac8e2b5b5d0eb222c4153' into codex/up…
qoli Oct 22, 2026
aeae6e1
Merge commit '170aa66ef7024b72cbcefcf13a08621c9b466f5a' into codex/up…
qoli Oct 22, 2026
163b679
Merge commit 'aeae6e1ce7b87692e0d6ac0b8535d67e537e2036' into codex/up…
qoli Oct 22, 2026
2e96883
Preserve final-request and cumulative usage across profile handoffs
qoli Oct 22, 2026
1d3f2a3
Merge upstream transport and final-request usage into OpenResponses p…
qoli Oct 22, 2026
2495f61
Merge upstream request accounting and stream updates
qoli Oct 23, 2026
5359f3d
Merge updated session-properties dependency
qoli Oct 23, 2026
cf4ad0d
Merge upstream per-request usage and isolate profile streams
qoli Oct 23, 2026
426ec21
Merge upstream request accounting into Open Responses profiles
qoli Oct 23, 2026
7c58b50
Merge origin/main into Open Responses profile tool loop
mattt Nov 5, 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
177 changes: 103 additions & 74 deletions Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import JSONSchema
/// model: "openai/gpt-4o-mini"
/// )
/// ```
public struct OpenResponsesLanguageModel: LanguageModel {
public struct OpenResponsesLanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel {
/// The reason the model is unavailable.
/// This model is always available.
public typealias UnavailableReason = Never
Expand Down Expand Up @@ -393,6 +393,7 @@ public struct OpenResponsesLanguageModel: LanguageModel {
public let responseStartTimeout: Duration?

private let transport: any HTTPTransport
let profileRequestIdentity = UUID()

/// Creates an Open Responses language model.
///
Expand Down Expand Up @@ -477,6 +478,7 @@ public struct OpenResponsesLanguageModel: LanguageModel {
options: GenerationOptions
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
return try await respondWithOpenResponses(
prompt: prompt,
generating: type,
schema: schema,
includeSchemaInPrompt: includeSchemaInPrompt,
Expand Down Expand Up @@ -541,23 +543,44 @@ public struct OpenResponsesLanguageModel: LanguageModel {
do {
var inFlightMessages: [OpenResponsesMessage] = []
var state = StreamingResponseState<Content>(wrapperProperty: responseSchema.wrapperProperty)
let toolRounds = ToolRoundLimit(provider: "Open Responses", options: options)
let toolRounds = session.toolRoundLimit(for: "Open Responses", options: options)
while true {
try Task.checkCancellation()
let requestContext = session.resolvedRequestContext()
let requestContext = try await session.resolvedRequestContext(
including: session.usesDynamicProfile ? state.entries : [],
options: options
)
if let handoff = session.profileContinuationStream(
ifSelectedModelDiffersFrom: profileRequestIdentity,
requestContext: requestContext,
prompt: prompt,
generating: type,
schema: schema,
includeSchemaInPrompt: includeSchemaInPrompt,
prefixEntries: state.entries,
prefixUsage: state.requests.total,
prefixText: state.responseText
) {
for try await snapshot in handoff { continuation.yield(snapshot) }
continuation.finish()
return
}
let tools: [OpenResponsesTool]? =
requestContext.tools.isEmpty
? nil : requestContext.tools.map(convertToolToOpenResponsesFormat)
let messages =
requestContext.transcript.saying(schemaInstructions).toOpenResponsesMessages()
+ inFlightMessages
let messages = openResponsesMessages(
from: requestContext.transcript.saying(schemaInstructions),
replacing: state.entries,
with: inFlightMessages,
hasDynamicProfile: requestContext.hasDynamicProfile
)
let params = try OpenResponsesAPI.createRequestBody(
model: model,
messages: messages,
tools: tools,
generating: type,
schema: responseSchema.schema,
options: options,
options: requestContext.options,
stream: true
)
let body = try JSONEncoder().encode(params)
Expand Down Expand Up @@ -602,19 +625,21 @@ public struct OpenResponsesLanguageModel: LanguageModel {
}
guard !toolCalls.isEmpty else { break }
try Task.checkCancellation()
try toolRounds.record(completed: state.entries)
switch try await resolveToolCalls(
try session.recordToolRound(toolRounds, completed: state.entries)
let (callsEntry, resolution) = try await resolveToolCalls(
toolCalls,
tools: requestContext.toolsForExecution(in: session),
requestContext: requestContext,
currentRoundEntries: state.entries,
session: session
) {
case .stop(let calls):
state.entries.append(.toolCalls(Transcript.ToolCalls(calls)))
)
switch resolution {
case .stop:
state.entries.append(.toolCalls(callsEntry))
continuation.yield(try state.stoppedSnapshot())
continuation.finish()
return
case .invocations(let invocations):
state.entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call))))
state.entries.append(.toolCalls(callsEntry))
for invocation in invocations {
state.entries.append(.toolOutput(invocation.output))
inFlightMessages.append(
Expand Down Expand Up @@ -642,6 +667,7 @@ public struct OpenResponsesLanguageModel: LanguageModel {

/// Sends a non-streaming request to the Open Responses API and returns the parsed response.
private func respondWithOpenResponses<Content>(
prompt: Prompt,
generating type: Content.Type,
schema: GenerationSchema,
includeSchemaInPrompt: Bool,
Expand All @@ -664,21 +690,41 @@ public struct OpenResponsesLanguageModel: LanguageModel {
var inFlightMessages: [OpenResponsesMessage] = []
let url = baseURL.appendingPathComponent("responses")

let toolRounds = ToolRoundLimit(provider: "Open Responses", options: options)
let toolRounds = session.toolRoundLimit(for: "Open Responses", options: options)
while true {
let requestContext = session.resolvedRequestContext()
let requestContext = try await session.resolvedRequestContext(
including: session.usesDynamicProfile ? entries : [],
options: options
)
if let continuation = try await session.profileContinuationResponse(
ifSelectedModelDiffersFrom: profileRequestIdentity,
requestContext: requestContext,
prompt: prompt,
generating: type,
schema: schema,
includeSchemaInPrompt: includeSchemaInPrompt,
prefixEntries: entries,
prefixUsage: usage.total,
prefixText: earlierText
) {
return continuation
}
let tools: [OpenResponsesTool]? =
requestContext.tools.isEmpty
? nil : requestContext.tools.map(convertToolToOpenResponsesFormat)
let messages =
requestContext.transcript.saying(schemaInstructions).toOpenResponsesMessages() + inFlightMessages
let messages = openResponsesMessages(
from: requestContext.transcript.saying(schemaInstructions),
replacing: entries,
with: inFlightMessages,
hasDynamicProfile: requestContext.hasDynamicProfile
)
let params = try OpenResponsesAPI.createRequestBody(
model: model,
messages: messages,
tools: tools,
generating: type,
schema: responseSchema.schema,
options: options,
options: requestContext.options,
stream: false
)
let body = try JSONEncoder().encode(params)
Expand All @@ -699,16 +745,17 @@ public struct OpenResponsesLanguageModel: LanguageModel {
)
}
}
try toolRounds.record(completed: entries)
let resolution = try await resolveToolCalls(
try session.recordToolRound(toolRounds, completed: entries)
let (callsEntry, resolution) = try await resolveToolCalls(
toolCalls,
tools: requestContext.toolsForExecution(in: session),
requestContext: requestContext,
currentRoundEntries: entries,
session: session
)
switch resolution {
case .stop(let calls):
if !calls.isEmpty {
entries.append(.toolCalls(Transcript.ToolCalls(calls)))
entries.append(.toolCalls(callsEntry))
}
let empty = try emptyResponseContent(for: type)
return LanguageModelSession.Response(
Expand All @@ -719,7 +766,7 @@ public struct OpenResponsesLanguageModel: LanguageModel {
)
case .invocations(let invocations):
if !invocations.isEmpty {
entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call })))
entries.append(.toolCalls(callsEntry))
for inv in invocations {
entries.append(.toolOutput(inv.output))
inFlightMessages.append(
Expand Down Expand Up @@ -1269,70 +1316,52 @@ private struct OpenResponsesToolInvocationResult: Sendable {
let output: Transcript.ToolOutput
}

private func openResponsesMessages(
from transcript: Transcript,
replacing currentRoundEntries: [Transcript.Entry],
with continuation: [OpenResponsesMessage],
hasDynamicProfile: Bool
) -> [OpenResponsesMessage] {
toolContinuationMessages(
from: transcript,
replacing: currentRoundEntries,
with: continuation,
hasDynamicProfile: hasDynamicProfile,
convert: { $0.toOpenResponsesMessages() }
)
}

private enum OpenResponsesToolResolutionOutcome: Sendable {
case stop(calls: [Transcript.ToolCall])
case invocations([OpenResponsesToolInvocationResult])
}

private func resolveToolCalls(
_ toolCalls: [OpenResponsesToolCall],
tools: [any Tool],
requestContext: LanguageModelSession.RequestContext,
currentRoundEntries: [Transcript.Entry],
session: LanguageModelSession
) async throws -> OpenResponsesToolResolutionOutcome {
if toolCalls.isEmpty { return .invocations([]) }
var byName: [String: any Tool] = [:]
for t in tools { if byName[t.name] == nil { byName[t.name] = t } }
) async throws -> (calls: Transcript.ToolCalls, outcome: OpenResponsesToolResolutionOutcome) {
var transcriptCalls: [Transcript.ToolCall] = []
for c in toolCalls {
let args = (c.arguments.flatMap { try? GeneratedContent(json: $0) } ?? GeneratedContent(properties: [:]))
transcriptCalls.append(Transcript.ToolCall(id: c.id, toolName: c.name, arguments: args))
}
if let d = session.toolExecutionDelegate {
await d.didGenerateToolCalls(transcriptCalls, in: session)
}
guard !transcriptCalls.isEmpty else { return .invocations([]) }
var decisions: [ToolExecutionDecision] = []
if let d = session.toolExecutionDelegate {
for call in transcriptCalls {
let dec = await d.toolCallDecision(for: call, in: session)
if case .stop = dec { return .stop(calls: transcriptCalls) }
decisions.append(dec)
}
} else {
decisions = Array(repeating: .execute, count: transcriptCalls.count)
}
var results: [OpenResponsesToolInvocationResult] = []
for (i, call) in transcriptCalls.enumerated() {
switch decisions[i] {
case .stop:
return .stop(calls: transcriptCalls)
case .provideOutput(let segs):
let out = Transcript.ToolOutput(id: call.id, toolName: call.toolName, segments: segs)
if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) }
results.append(OpenResponsesToolInvocationResult(call: call, output: out))
case .execute:
guard let tool = byName[call.toolName] else {
let out = Transcript.ToolOutput(
id: call.id,
toolName: call.toolName,
segments: [.text(.init(content: "Tool not found: \(call.toolName)"))]
)
if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) }
results.append(OpenResponsesToolInvocationResult(call: call, output: out))
continue
}
do {
let segs = try await tool.makeOutputSegments(from: call.arguments)
let out = Transcript.ToolOutput(id: call.id, toolName: tool.name, segments: segs)
if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) }
results.append(OpenResponsesToolInvocationResult(call: call, output: out))
} catch {
if let d = session.toolExecutionDelegate { await d.didFailToolCall(call, error: error, in: session) }
throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error)
}
}
let callsEntry = Transcript.ToolCalls(transcriptCalls)
let entries = currentRoundEntries + [.toolCalls(callsEntry)]
switch try await session.resolveProfileToolCalls(
transcriptCalls,
requestContext: requestContext,
currentRoundEntries: entries
) {
case .stop(let calls):
return (callsEntry, .stop(calls: calls))
case .invocations(let invocations):
return (
callsEntry,
.invocations(invocations.map { .init(call: $0.call, output: $0.output) })
)
}
return .invocations(results)
}

// MARK: - Streaming events
Expand Down
53 changes: 53 additions & 0 deletions Tests/AnyLanguageModelTests/OpenResponsesProfileTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Testing

@_spi(Compatibility) @testable import AnyLanguageModel

#if canImport(Darwin) && !canImport(AsyncHTTPClient)
extension ProviderUsageTests {
@Test(arguments: [false, true])
func openResponsesReusedCallIDs(_ streaming: Bool) async throws {
for profile in [false, true] {
try await providerReusedCallIDsPreserveEveryOutput(
.openResponses,
streaming: streaming,
profile: profile
)
}
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesProfileReevaluation(_ provider: Provider, _ streaming: Bool) async throws {
try await providerProfileReevaluatesBeforeEveryToolContinuation(provider, streaming)
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesModelHandoff(_ provider: Provider, _ streaming: Bool) async throws {
try await providerProfileCanSwitchModelForToolContinuation(provider, streaming)
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesSchemaPromptHandoff(_ provider: Provider, _ streaming: Bool) async throws {
try await modelHandoffsRestoreOriginalSchemaPromptOption(provider, streaming)
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesHistoryAcrossHandoffs(_ provider: Provider, _ streaming: Bool) async throws {
try await modelHandoffsPreserveEarlierToolResultsAndHistoryWrites(provider, streaming)
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesSharedToolRoundBudget(_ provider: Provider, _ streaming: Bool) async throws {
try await providerModelHandoffsShareToolRoundLimit(provider, streaming)
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesFailedHandoffHistory(_ provider: Provider, _ streaming: Bool) async throws {
try await failedHandoffDoesNotPersistPendingHistory(provider, streaming)
}

@Test(arguments: [Provider.openResponses], [false, true])
func openResponsesOrdinaryToolLoop(_ provider: Provider, _ streaming: Bool) async throws {
try await ordinaryWrapperKeepsTheProviderToolLoop(provider, streaming)
}
}
#endif
Loading