Skip to content

Commit 055e80f

Browse files
committed
add(replay): gate openai assistant reasoning replay by explicit profile
1 parent f338f33 commit 055e80f

7 files changed

Lines changed: 359 additions & 163 deletions

File tree

Sources/AgentRunKit/Documentation.docc/AgentRunKit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ For a complete walkthrough, see <doc:GettingStarted>.
7676
- <doc:LLMProviders>
7777
- ``LLMClient``
7878
- ``OpenAIClient``
79+
- ``OpenAIChatAssistantReplayProfile``
7980
- ``AnthropicClient``
8081
- ``AnthropicReasoningOptions``
8182
- ``GeminiClient``

Sources/AgentRunKit/Documentation.docc/Articles/LLMProviders.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ Three clients preserve same-substrate continuity state, restoring provider-nativ
3939

4040
Other clients (``OpenAIClient``, ``GeminiClient``) use semantic-only replay. History is reconstructed from the semantic fields, which is sufficient for the agent loop but does not preserve provider-specific turn metadata.
4141

42+
### Assistant Reasoning Replay on Chat Completions
43+
44+
``OpenAIClient`` parses reasoning fields (`reasoning`, `reasoning_content`, `reasoning_details`) from all provider responses. However, replaying those fields back onto later assistant turns is not universally safe across the diverse Chat Completions ecosystem.
45+
46+
Outbound replay is controlled by ``OpenAIChatAssistantReplayProfile``, which defaults to `.conservative` (omit all assistant-local reasoning fields from requests). This is the correct default because first-party OpenAI routes reasoning continuity through the Responses API, and other providers have heterogeneous replay contracts.
47+
48+
One opt-in profile is available:
49+
50+
- `.openRouterReasoningDetails`: emits `reasoning_details` on assistant turns, matching OpenRouter's documented contract for preserving encrypted reasoning blocks across turns. Does not emit `reasoning_content`.
51+
52+
```swift
53+
let client = OpenAIClient(
54+
apiKey: "sk-or-...",
55+
model: "anthropic/claude-sonnet-4",
56+
baseURL: OpenAIClient.openRouterBaseURL,
57+
reasoningConfig: .high,
58+
assistantReplayProfile: .openRouterReasoningDetails
59+
)
60+
```
61+
62+
Together's preserved-thinking replay depends on a provider-specific mode (`clear_thinking`) not yet modeled by the client, so it remains conservative in this release. For first-party OpenAI reasoning continuity, use ``ResponsesAPIClient``.
63+
4264
## ResponsesAPIClient vs OpenAIClient
4365

4466
Both connect to OpenAI. ``OpenAIClient`` uses the Chat Completions API, a stateless request/response protocol shared by many compatible providers (OpenRouter, Groq, Together, Ollama). ``ResponsesAPIClient`` uses the Responses API, which maintains server-side conversation state and supports delta requests that send only new messages since the last response.

Sources/AgentRunKit/LLM/OpenAIClient.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public struct OpenAIClient: LLMClient, Sendable {
1212
let session: URLSession
1313
let retryPolicy: RetryPolicy
1414
let reasoningConfig: ReasoningConfig?
15+
let assistantReplayProfile: OpenAIChatAssistantReplayProfile
1516

1617
public init(
1718
apiKey: String? = nil,
@@ -23,7 +24,8 @@ public struct OpenAIClient: LLMClient, Sendable {
2324
additionalHeaders: @Sendable @escaping () -> [String: String] = { [:] },
2425
session: URLSession = .shared,
2526
retryPolicy: RetryPolicy = .default,
26-
reasoningConfig: ReasoningConfig? = nil
27+
reasoningConfig: ReasoningConfig? = nil,
28+
assistantReplayProfile: OpenAIChatAssistantReplayProfile = .conservative
2729
) {
2830
self.apiKey = apiKey
2931
modelIdentifier = model
@@ -35,6 +37,7 @@ public struct OpenAIClient: LLMClient, Sendable {
3537
self.session = session
3638
self.retryPolicy = retryPolicy
3739
self.reasoningConfig = reasoningConfig
40+
self.assistantReplayProfile = assistantReplayProfile
3841
}
3942

4043
public func generate(
@@ -140,7 +143,7 @@ extension OpenAIClient {
140143
let tokenField = baseURL == OpenAIClient.openAIBaseURL ? "max_completion_tokens" : "max_tokens"
141144
return ChatCompletionRequest(
142145
model: modelIdentifier,
143-
messages: messages.map(RequestMessage.init),
146+
messages: messages.map { RequestMessage($0, replayProfile: assistantReplayProfile) },
144147
tools: tools.isEmpty ? nil : tools.map(RequestTool.init),
145148
toolChoice: tools.isEmpty ? nil : "auto",
146149
maxTokens: maxTokens,
@@ -328,7 +331,8 @@ public extension OpenAIClient {
328331
additionalHeaders: @Sendable @escaping () -> [String: String] = { [:] },
329332
session: URLSession = .shared,
330333
retryPolicy: RetryPolicy = .default,
331-
reasoningConfig: ReasoningConfig? = nil
334+
reasoningConfig: ReasoningConfig? = nil,
335+
assistantReplayProfile: OpenAIChatAssistantReplayProfile = .conservative
332336
) -> OpenAIClient {
333337
OpenAIClient(
334338
apiKey: nil,
@@ -340,7 +344,8 @@ public extension OpenAIClient {
340344
additionalHeaders: additionalHeaders,
341345
session: session,
342346
retryPolicy: retryPolicy,
343-
reasoningConfig: reasoningConfig
347+
reasoningConfig: reasoningConfig,
348+
assistantReplayProfile: assistantReplayProfile
344349
)
345350
}
346351
}

Sources/AgentRunKit/LLM/OpenAIClientTypes.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import Foundation
22

3+
/// Controls which assistant-local reasoning fields are replayed onto outbound Chat Completions requests.
4+
public enum OpenAIChatAssistantReplayProfile: Sendable, Equatable {
5+
case conservative
6+
case openRouterReasoningDetails
7+
}
8+
9+
extension OpenAIChatAssistantReplayProfile {
10+
var emitsReasoningDetails: Bool {
11+
switch self {
12+
case .conservative: false
13+
case .openRouterReasoningDetails: true
14+
}
15+
}
16+
}
17+
318
/// Per-request metadata and provider-specific parameters.
419
public struct RequestContext: Sendable {
520
public let extraFields: [String: JSONValue]
@@ -138,7 +153,7 @@ struct RequestMessage: Encodable {
138153
}
139154
}
140155

141-
init(_ message: ChatMessage) {
156+
init(_ message: ChatMessage, replayProfile: OpenAIChatAssistantReplayProfile) {
142157
switch message {
143158
case let .system(text):
144159
role = "system"
@@ -170,8 +185,8 @@ struct RequestMessage: Encodable {
170185
toolCalls = msg.toolCalls.isEmpty ? nil : msg.toolCalls.map(RequestToolCall.init)
171186
toolCallId = nil
172187
name = nil
173-
reasoningContent = msg.reasoning?.content
174-
reasoningDetails = msg.reasoningDetails
188+
reasoningContent = nil
189+
reasoningDetails = replayProfile.emitsReasoningDetails ? msg.reasoningDetails : nil
175190
case let .tool(id, toolName, resultContent):
176191
role = "tool"
177192
content = .text(resultContent)

Tests/AgentRunKitTests/LLMClientTests.swift

Lines changed: 0 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -662,162 +662,6 @@ struct ReasoningConfigTests {
662662
}
663663
}
664664

665-
struct ReasoningMultiTurnTests {
666-
@Test
667-
func assistantMessageWithReasoningEncodes() throws {
668-
let reasoning = ReasoningContent(content: "Let me think about this...")
669-
let assistantMsg = AssistantMessage(content: "The answer is 42", reasoning: reasoning)
670-
let client = OpenAIClient(
671-
apiKey: "test-key",
672-
model: "test/model",
673-
baseURL: OpenAIClient.openRouterBaseURL
674-
)
675-
let messages: [ChatMessage] = [.assistant(assistantMsg)]
676-
let request = client.buildRequest(messages: messages, tools: [])
677-
678-
let encoder = JSONEncoder()
679-
let data = try encoder.encode(request)
680-
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
681-
682-
let jsonMessages = json?["messages"] as? [[String: Any]]
683-
let msg = jsonMessages?[0]
684-
#expect(msg?["role"] as? String == "assistant")
685-
#expect(msg?["content"] as? String == "The answer is 42")
686-
#expect(msg?["reasoning_content"] as? String == "Let me think about this...")
687-
}
688-
689-
@Test
690-
func assistantMessageWithReasoningDetailsEncodes() throws {
691-
let details: [JSONValue] = [
692-
.object([
693-
"type": .string("reasoning.encrypted"),
694-
"encrypted": .string("base64blob=="),
695-
"id": .string("re_001")
696-
])
697-
]
698-
let assistantMsg = AssistantMessage(content: "Result", reasoningDetails: details)
699-
let client = OpenAIClient(
700-
apiKey: "test-key",
701-
model: "test/model",
702-
baseURL: OpenAIClient.openRouterBaseURL
703-
)
704-
let messages: [ChatMessage] = [.assistant(assistantMsg)]
705-
let request = client.buildRequest(messages: messages, tools: [])
706-
707-
let encoder = JSONEncoder()
708-
let data = try encoder.encode(request)
709-
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
710-
711-
let jsonMessages = json?["messages"] as? [[String: Any]]
712-
let msg = jsonMessages?[0]
713-
let encodedDetails = msg?["reasoning_details"] as? [[String: Any]]
714-
#expect(encodedDetails?.count == 1)
715-
#expect(encodedDetails?[0]["type"] as? String == "reasoning.encrypted")
716-
#expect(encodedDetails?[0]["encrypted"] as? String == "base64blob==")
717-
#expect(encodedDetails?[0]["id"] as? String == "re_001")
718-
}
719-
720-
@Test
721-
func assistantMessageWithoutReasoningDetailsOmitsField() throws {
722-
let assistantMsg = AssistantMessage(content: "Simple")
723-
let client = OpenAIClient(
724-
apiKey: "test-key",
725-
model: "test/model",
726-
baseURL: OpenAIClient.openRouterBaseURL
727-
)
728-
let messages: [ChatMessage] = [.assistant(assistantMsg)]
729-
let request = client.buildRequest(messages: messages, tools: [])
730-
731-
let encoder = JSONEncoder()
732-
let data = try encoder.encode(request)
733-
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
734-
735-
let jsonMessages = json?["messages"] as? [[String: Any]]
736-
let msg = jsonMessages?[0]
737-
#expect(msg?["reasoning_details"] == nil)
738-
}
739-
740-
@Test
741-
func reasoningDetailsRoundTripPreservesSnakeCaseKeys() throws {
742-
let details: [JSONValue] = [
743-
.object([
744-
"type": .string("reasoning.text"),
745-
"reasoning_type": .string("chain_of_thought"),
746-
"inner_data": .object(["nested_key": .string("value")])
747-
])
748-
]
749-
let assistantMsg = AssistantMessage(content: "Result", reasoningDetails: details)
750-
let client = OpenAIClient(
751-
apiKey: "test-key",
752-
model: "test/model",
753-
baseURL: OpenAIClient.openRouterBaseURL
754-
)
755-
let messages: [ChatMessage] = [.assistant(assistantMsg)]
756-
let request = client.buildRequest(messages: messages, tools: [])
757-
758-
let encoder = JSONEncoder()
759-
let data = try encoder.encode(request)
760-
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
761-
762-
let jsonMessages = json?["messages"] as? [[String: Any]]
763-
let msg = jsonMessages?[0]
764-
let encodedDetails = msg?["reasoning_details"] as? [[String: Any]]
765-
let obj = encodedDetails?[0]
766-
#expect(obj?["reasoning_type"] as? String == "chain_of_thought")
767-
let inner = obj?["inner_data"] as? [String: Any]
768-
#expect(inner?["nested_key"] as? String == "value")
769-
#expect(obj?["reasoningType"] == nil, "snake_case keys must survive the round-trip unchanged")
770-
}
771-
772-
@Test
773-
func assistantMessageWithoutReasoningOmitsField() throws {
774-
let assistantMsg = AssistantMessage(content: "The answer is 42")
775-
let client = OpenAIClient(
776-
apiKey: "test-key",
777-
model: "test/model",
778-
baseURL: OpenAIClient.openRouterBaseURL
779-
)
780-
let messages: [ChatMessage] = [.assistant(assistantMsg)]
781-
let request = client.buildRequest(messages: messages, tools: [])
782-
783-
let encoder = JSONEncoder()
784-
let data = try encoder.encode(request)
785-
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
786-
787-
let jsonMessages = json?["messages"] as? [[String: Any]]
788-
let msg = jsonMessages?[0]
789-
#expect(msg?["reasoning_content"] == nil)
790-
}
791-
792-
@Test
793-
func assistantMessageWithToolCallsAndReasoningEncodes() throws {
794-
let reasoning = ReasoningContent(content: "I need to check the weather...")
795-
let toolCall = ToolCall(id: "call_123", name: "get_weather", arguments: "{\"city\":\"NYC\"}")
796-
let assistantMsg = AssistantMessage(
797-
content: "Let me check",
798-
toolCalls: [toolCall],
799-
reasoning: reasoning
800-
)
801-
let client = OpenAIClient(
802-
apiKey: "test-key",
803-
model: "test/model",
804-
baseURL: OpenAIClient.openRouterBaseURL
805-
)
806-
let messages: [ChatMessage] = [.assistant(assistantMsg)]
807-
let request = client.buildRequest(messages: messages, tools: [])
808-
809-
let encoder = JSONEncoder()
810-
let data = try encoder.encode(request)
811-
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
812-
813-
let jsonMessages = json?["messages"] as? [[String: Any]]
814-
let msg = jsonMessages?[0]
815-
#expect(msg?["reasoning_content"] as? String == "I need to check the weather...")
816-
let jsonToolCalls = msg?["tool_calls"] as? [[String: Any]]
817-
#expect(jsonToolCalls?.count == 1)
818-
}
819-
}
820-
821665
struct ProxyModeTests {
822666
@Test
823667
func proxyFactoryCreatesClientWithoutApiKeyOrModel() throws {

0 commit comments

Comments
 (0)