11import Foundation
2+ import JSONSchema
23import Testing
34
45@testable import AnyLanguageModel
@@ -14,7 +15,6 @@ struct OpenAILanguageModelTests {
1415 }
1516
1617 @Test func apiVariantParameterization( ) throws {
17- // Test that both API variants can be created and have correct properties
1818 for apiVariant in [ OpenAILanguageModel . APIVariant. chatCompletions, . responses] {
1919 let model = OpenAILanguageModel ( apiKey: " test-key " , model: " test-model " , apiVariant: apiVariant)
2020 #expect( model. apiVariant == apiVariant)
@@ -97,7 +97,6 @@ struct OpenAILanguageModelTests {
9797 maximumResponseTokens: 50
9898 )
9999
100- // Set custom options (extraBody will be merged into the request)
101100 options [ custom: OpenAILanguageModel . self] = . init(
102101 extraBody: [ " user " : . string( " test-user-id " ) ]
103102 )
@@ -138,19 +137,77 @@ struct OpenAILanguageModelTests {
138137 }
139138
140139 @Test func withTools( ) async throws {
141- let weatherTool = WeatherTool ( )
140+ let weatherTool = spy ( on : WeatherTool ( ) )
142141 let session = LanguageModelSession ( model: model, tools: [ weatherTool] )
143142
144- let response = try await session. respond ( to: " How's the weather in San Francisco? " )
143+ var options = GenerationOptions ( )
144+ options [ custom: OpenAILanguageModel . self] = . init(
145+ maxToolCalls: 1
146+ )
147+
148+ let response = try await withOpenAIRateLimitRetry {
149+ try await session. respond (
150+ to: " Call getWeather for San Francisco exactly once, then summarize in one sentence. " ,
151+ options: options
152+ )
153+ }
154+
155+ #expect( !response. content. isEmpty)
156+ let calls = await weatherTool. calls
157+ #expect( !calls. isEmpty)
158+ if let firstCall = calls. first {
159+ #expect( firstCall. arguments. city. localizedCaseInsensitiveContains ( " san " ) )
160+ }
145161
162+ var foundToolCall = false
146163 var foundToolOutput = false
147- for case let . toolOutput( toolOutput) in response. transcriptEntries {
148- #expect( toolOutput. toolName == " getWeather " )
149- foundToolOutput = true
164+ for entry in response. transcriptEntries {
165+ switch entry {
166+ case . toolCalls( let toolCalls) :
167+ #expect( !toolCalls. isEmpty)
168+ if let firstToolCall = toolCalls. first {
169+ #expect( firstToolCall. toolName == " getWeather " )
170+ }
171+ foundToolCall = true
172+ case . toolOutput( let toolOutput) :
173+ #expect( toolOutput. toolName == " getWeather " )
174+ foundToolOutput = true
175+ default :
176+ break
177+ }
150178 }
179+ #expect( foundToolCall)
151180 #expect( foundToolOutput)
152181 }
153182
183+ @Test func withToolsConversationContinuesAcrossTurns( ) async throws {
184+ let weatherTool = spy ( on: WeatherTool ( ) )
185+ let session = LanguageModelSession ( model: model, tools: [ weatherTool] )
186+
187+ var options = GenerationOptions ( )
188+ options [ custom: OpenAILanguageModel . self] = . init(
189+ maxToolCalls: 1
190+ )
191+
192+ _ = try await withOpenAIRateLimitRetry {
193+ try await session. respond (
194+ to: " Call getWeather for San Francisco exactly once, then reply with only: done " ,
195+ options: options
196+ )
197+ }
198+
199+ let secondResponse = try await withOpenAIRateLimitRetry {
200+ try await session. respond (
201+ to: " Which city did the tool call use? Reply with city only. "
202+ )
203+ }
204+ #expect( !secondResponse. content. isEmpty)
205+ #expect( secondResponse. content. localizedCaseInsensitiveContains ( " san " ) )
206+
207+ let calls = await weatherTool. calls
208+ #expect( calls. count >= 1 )
209+ }
210+
154211 @Suite ( " Structured Output " )
155212 struct StructuredOutputTests {
156213 @Generable
@@ -316,7 +373,6 @@ struct OpenAILanguageModelTests {
316373 maximumResponseTokens: 50
317374 )
318375
319- // Set custom options (extraBody will be merged into the request)
320376 options [ custom: OpenAILanguageModel . self] = . init(
321377 extraBody: [ " user " : " test-user-id " ]
322378 )
@@ -459,4 +515,73 @@ struct OpenAILanguageModelTests {
459515 }
460516 }
461517 }
518+
519+ @Suite ( " OpenAILanguageModel Responses Request Body " )
520+ struct ResponsesRequestBodyTests {
521+ private let model = " test-model "
522+
523+ private func inputArray( from body: JSONValue ) -> [ JSONValue ] ? {
524+ guard case let . object( obj) = body else { return nil }
525+ guard case let . array( input) ? = obj [ " input " ] else { return nil }
526+ return input
527+ }
528+
529+ private func stringValue( _ value: JSONValue ? ) -> String ? {
530+ guard case let . string( text) ? = value else { return nil }
531+ return text
532+ }
533+
534+ private func firstObject( withType type: String , in input: [ JSONValue ] ) -> [ String : JSONValue ] ? {
535+ for value in input {
536+ guard case let . object( obj) = value else { continue }
537+ guard case let . string( foundType) ? = obj [ " type " ] , foundType == type else { continue }
538+ return obj
539+ }
540+ return nil
541+ }
542+
543+ private func containsKey( _ value: JSONValue , key: String ) -> Bool {
544+ guard case let . object( obj) = value else { return false }
545+ return obj [ key] != nil
546+ }
547+
548+ private func makePrompt( _ text: String = " Continue. " ) -> Transcript . Prompt {
549+ Transcript . Prompt ( segments: [ . text( . init( content: text) ) ] )
550+ }
551+
552+ private func makeTranscriptWithToolCalls( ) throws -> Transcript {
553+ let arguments = try GeneratedContent ( json: #"{"city":"Paris"}"# )
554+ let call = Transcript . ToolCall ( id: " call-1 " , toolName: " getWeather " , arguments: arguments)
555+ let toolCalls = Transcript . ToolCalls ( [ call] )
556+ return Transcript ( entries: [
557+ . toolCalls( toolCalls) ,
558+ . prompt( makePrompt ( ) ) ,
559+ ] )
560+ }
561+ }
562+ }
563+
564+ private func withOpenAIRateLimitRetry< T> (
565+ maxAttempts: Int = 4 ,
566+ operation: @escaping ( ) async throws -> T
567+ ) async throws -> T {
568+ var attempt = 1
569+ while true {
570+ do {
571+ return try await operation ( )
572+ } catch let error as URLSessionError {
573+ if case . httpError( _, let detail) = error,
574+ detail. contains ( " rate_limit_exceeded " ) ,
575+ attempt < maxAttempts
576+ {
577+ let delaySeconds = UInt64 ( attempt)
578+ try await Task . sleep ( nanoseconds: delaySeconds * 1_000_000_000 )
579+ attempt += 1
580+ continue
581+ }
582+ throw error
583+ } catch {
584+ throw error
585+ }
586+ }
462587}
0 commit comments