Skip to content

Commit 1f5bb24

Browse files
committed
add(openrouter): responses factory and reasoning-replay test coverage
1 parent 452c0f7 commit 1f5bb24

6 files changed

Lines changed: 243 additions & 6 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ let client = OpenAIClient.openRouter(
6767

6868
`OpenAIClient.openRouter(...)` pins ``OpenAIChatProfile/openRouter`` and defaults ``OpenAIChatAssistantReplayProfile`` to `.openRouterReasoningDetails`.
6969

70+
Reasoning replay is best-effort. Some models return no `reasoning_details` on a given turn (for example GPT-class models routed through Chat Completions, which expose replayable reasoning only through the Responses API). When that happens the assistant turn carries no replayable reasoning and `reasoning_details` is absent from the next request. A workflow that requires replayable reasoning continuity can detect this by inspecting the reasoning details on the returned assistant message, such as the last assistant entry in ``AgentResult/history``.
71+
7072
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, and for Responses-native OpenRouter models such as xAI Grok, use ``ResponsesAPIClient`` instead. See `Targeting OpenRouter with ResponsesAPIClient` below.
7173

7274
## ResponsesAPIClient vs OpenAIClient
@@ -75,16 +77,14 @@ Together's preserved-thinking replay depends on a provider-specific mode (`clear
7577

7678
### Targeting OpenRouter with ResponsesAPIClient
7779

78-
``ResponsesAPIClient`` is not locked to OpenAI. It accepts any base URL and works with OpenRouter's `/v1/responses` endpoint for models that OpenRouter routes through the Responses protocol. Point it at `OpenAIClient.openRouterBaseURL`:
80+
``ResponsesAPIClient`` is not locked to OpenAI. It accepts any base URL and works with OpenRouter's `/v1/responses` endpoint for models that OpenRouter routes through the Responses protocol. The `ResponsesAPIClient.openRouter(...)` factory pins ``ResponsesAPIClient/openRouterBaseURL`` and `store: false`:
7981

8082
```swift
81-
let client = ResponsesAPIClient(
83+
let client = ResponsesAPIClient.openRouter(
8284
apiKey: "sk-or-...",
8385
model: "x-ai/grok-4",
8486
maxOutputTokens: 4096,
85-
baseURL: OpenAIClient.openRouterBaseURL,
86-
reasoningConfig: .high,
87-
store: false
87+
reasoningConfig: .high
8888
)
8989
```
9090

@@ -93,7 +93,7 @@ Prefer ``ResponsesAPIClient`` over ``OpenAIClient`` on OpenRouter when:
9393
- The target model is Responses-API-native rather than Chat-Completions-native.
9494
- Provider-native reasoning continuity depends on preserving full Responses output items across turns.
9595

96-
xAI Grok models are the canonical case. Grok returns encrypted reasoning artifacts as Responses output items, and ``ResponsesAPIClient`` preserves those items in ``AssistantContinuity`` for lossless replay on the next turn. ``OpenAIClient`` with `.openRouterReasoningDetails` flattens reasoning back to Chat Completions `reasoning_details`, which is the right contract for Chat-Completions-native OpenRouter models but not for Responses-native Grok. Set `store: false` on ``ResponsesAPIClient`` when targeting OpenRouter: this makes the client request `reasoning.encrypted_content` and send full history on every call, and it disables `previous_response_id` continuation, which matches OpenRouter's stateless Responses routing.
96+
xAI Grok models are the canonical case. Grok returns encrypted reasoning artifacts as Responses output items, and ``ResponsesAPIClient`` preserves those items in ``AssistantContinuity`` for lossless replay on the next turn. ``OpenAIClient`` with `.openRouterReasoningDetails` flattens reasoning back to Chat Completions `reasoning_details`, which is the right contract for Chat-Completions-native OpenRouter models but not for Responses-native Grok. The `openRouter(...)` factory sets `store: false`: this makes the client request `reasoning.encrypted_content` and send full history on every call, and it disables `previous_response_id` continuation, which matches OpenRouter's stateless Responses routing.
9797

9898
``OpenAIClient`` remains the correct Chat Completions transport for OpenRouter models that are not Responses-native. The two clients are independent paths, not substitutes: pick the one the target model speaks.
9999

Sources/AgentRunKit/LLM/Providers/Responses/ResponsesAPIClient.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,24 @@ public extension ResponsesAPIClient {
355355
URL(validStaticString: "https://api.openai.com/v1")
356356
nonisolated static let chatGPTBaseURL =
357357
URL(validStaticString: "https://chatgpt.com/backend-api/codex")
358+
nonisolated static let openRouterBaseURL =
359+
URL(validStaticString: "https://openrouter.ai/api/v1")
360+
361+
static func openRouter(
362+
apiKey: String,
363+
model: String? = nil,
364+
maxOutputTokens: Int? = nil,
365+
contextWindowSize: Int? = nil,
366+
reasoningConfig: ReasoningConfig? = nil
367+
) -> ResponsesAPIClient {
368+
ResponsesAPIClient(
369+
apiKey: apiKey,
370+
model: model,
371+
maxOutputTokens: maxOutputTokens,
372+
contextWindowSize: contextWindowSize,
373+
baseURL: openRouterBaseURL,
374+
reasoningConfig: reasoningConfig,
375+
store: false
376+
)
377+
}
358378
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
@testable import AgentRunKit
2+
import Foundation
3+
import Testing
4+
5+
struct OpenAIChatStreamingReasoningTests {
6+
private func makeClient(baseURL: URL, session: URLSession) -> OpenAIClient {
7+
OpenAIClient(
8+
apiKey: "test-key",
9+
model: "anthropic/claude-opus-4.8",
10+
baseURL: baseURL,
11+
session: session,
12+
assistantReplayProfile: .openRouterReasoningDetails
13+
)
14+
}
15+
16+
private func sseLine(_ json: String) -> String {
17+
"data: \(json)"
18+
}
19+
20+
private func reasoningEvent(_ detail: String) -> String {
21+
sseLine(#"{"choices":[{"index":0,"delta":{"reasoning_details":[\#(detail)]}}]}"#)
22+
}
23+
24+
private func reasoningDetailsStreamBody() -> Data {
25+
let hello = #"{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"text":"Hello"}"#
26+
let world = #"{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"text":" world"}"#
27+
let signature = #"{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"signature":"sig-abc"}"#
28+
let events = [
29+
reasoningEvent(hello),
30+
reasoningEvent(world),
31+
reasoningEvent(signature),
32+
sseLine(#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#),
33+
]
34+
return Data((events.joined(separator: "\n\n") + "\n\ndata: [DONE]\n\n").utf8)
35+
}
36+
37+
@Test
38+
func streamingReasoningDetailDeltasDecodedFromBytes() async throws {
39+
let session = URLSession(configuration: StreamingTestURLProtocol.configuration())
40+
defer { session.invalidateAndCancel() }
41+
let client = try makeClient(
42+
baseURL: #require(URL(string: "https://openrouter-reasoning-deltas.test/v1")),
43+
session: session
44+
)
45+
let request = try client.buildRequest(messages: [.user("Hi")], tools: [], stream: true)
46+
let requestURL = try #require(client.buildURLRequest(request).url)
47+
StreamingTestURLProtocol.register(url: requestURL, body: reasoningDetailsStreamBody())
48+
defer { StreamingTestURLProtocol.unregister(url: requestURL) }
49+
50+
let (deltas, error) = await collectStreamResult(
51+
client.stream(messages: [.user("Hi")], tools: [], requestContext: nil)
52+
)
53+
#expect(error == nil)
54+
55+
let reasoningDeltas = deltas.compactMap { delta -> [JSONValue]? in
56+
guard case let .reasoningDetails(details) = delta else { return nil }
57+
return details
58+
}
59+
#expect(reasoningDeltas.count == 3)
60+
#expect(reasoningDeltas.first == [.object([
61+
"type": .string("reasoning.text"),
62+
"format": .string("anthropic-claude-v1"),
63+
"index": .int(0),
64+
"text": .string("Hello"),
65+
])])
66+
#expect(reasoningDeltas.last == [.object([
67+
"type": .string("reasoning.text"),
68+
"format": .string("anthropic-claude-v1"),
69+
"index": .int(0),
70+
"signature": .string("sig-abc"),
71+
])])
72+
}
73+
74+
@Test
75+
func streamingReasoningDetailsConsolidateIntoSingleBlock() async throws {
76+
let session = URLSession(configuration: StreamingTestURLProtocol.configuration())
77+
defer { session.invalidateAndCancel() }
78+
let client = try makeClient(
79+
baseURL: #require(URL(string: "https://openrouter-reasoning-merge.test/v1")),
80+
session: session
81+
)
82+
let request = try client.buildRequest(messages: [.user("Hi")], tools: [], stream: true)
83+
let requestURL = try #require(client.buildURLRequest(request).url)
84+
StreamingTestURLProtocol.register(url: requestURL, body: reasoningDetailsStreamBody())
85+
defer { StreamingTestURLProtocol.unregister(url: requestURL) }
86+
87+
let processor = StreamProcessor(
88+
client: client,
89+
toolDefinitions: [],
90+
policy: .chat,
91+
eventFactory: StreamEventFactory(sessionID: nil, runID: nil, origin: .live)
92+
)
93+
let (_, continuation) = AsyncThrowingStream<StreamEvent, Error>.makeStream()
94+
var totalUsage = TokenUsage()
95+
let iteration = try await processor.process(
96+
messages: [.user("Hi")],
97+
totalUsage: &totalUsage,
98+
continuation: continuation
99+
)
100+
101+
let details = try #require(iteration.toAssistantMessage().reasoningDetails)
102+
#expect(details.count == 1)
103+
#expect(details[0] == .object([
104+
"type": .string("reasoning.text"),
105+
"format": .string("anthropic-claude-v1"),
106+
"index": .int(0),
107+
"text": .string("Hello world"),
108+
"signature": .string("sig-abc"),
109+
]))
110+
}
111+
}

Tests/AgentRunKitTests/LLM/OpenAIChat/OpenAIReplayProfileTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,46 @@ struct ReasoningMultiTurnTests {
214214
let jsonToolCalls = msg?["tool_calls"] as? [[String: Any]]
215215
#expect(jsonToolCalls?.count == 1)
216216
}
217+
218+
@Test
219+
func openRouterProfileEmitsReasoningDetailsAlongsideToolCalls() throws {
220+
let details: [JSONValue] = [
221+
.object([
222+
"type": .string("reasoning.text"),
223+
"format": .string("anthropic-claude-v1"),
224+
"index": .int(0),
225+
"text": .string("Let me check the weather"),
226+
"signature": .string("sig-abc"),
227+
]),
228+
]
229+
let toolCall = ToolCall(id: "call_123", name: "get_weather", arguments: "{\"city\":\"NYC\"}")
230+
let assistantMsg = AssistantMessage(
231+
content: "Checking",
232+
toolCalls: [toolCall],
233+
reasoningDetails: details
234+
)
235+
let client = OpenAIClient(
236+
apiKey: "test-key",
237+
model: "test/model",
238+
baseURL: OpenAIClient.openRouterBaseURL,
239+
assistantReplayProfile: .openRouterReasoningDetails
240+
)
241+
let messages: [ChatMessage] = [.assistant(assistantMsg)]
242+
let request = try client.buildRequest(messages: messages, tools: [])
243+
244+
let data = try JSONEncoder().encode(request)
245+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
246+
247+
let jsonMessages = json?["messages"] as? [[String: Any]]
248+
let msg = jsonMessages?[0]
249+
let encodedDetails = msg?["reasoning_details"] as? [[String: Any]]
250+
#expect(encodedDetails?.count == 1)
251+
#expect(encodedDetails?[0]["type"] as? String == "reasoning.text")
252+
#expect(encodedDetails?[0]["signature"] as? String == "sig-abc")
253+
let jsonToolCalls = msg?["tool_calls"] as? [[String: Any]]
254+
#expect(jsonToolCalls?.count == 1)
255+
#expect(jsonToolCalls?[0]["id"] as? String == "call_123")
256+
}
217257
}
218258

219259
struct ReplayProfileDefaultTests {

Tests/AgentRunKitTests/LLM/Responses/ResponsesAPIClientTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ struct ResponsesRequestSerializationTests {
1717
func baseURLConstantsExposeOpenAIAndChatGPTEndpoints() {
1818
#expect(ResponsesAPIClient.openAIBaseURL.absoluteString == "https://api.openai.com/v1")
1919
#expect(ResponsesAPIClient.chatGPTBaseURL.absoluteString == "https://chatgpt.com/backend-api/codex")
20+
#expect(ResponsesAPIClient.openRouterBaseURL.absoluteString == "https://openrouter.ai/api/v1")
2021
}
2122

2223
@Test
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
@testable import AgentRunKit
2+
import Foundation
3+
import Testing
4+
5+
struct ResponsesOpenRouterReplayTests {
6+
@Test
7+
func openRouterFactoryTargetsStatelessEncryptedReasoning() async throws {
8+
let client = ResponsesAPIClient.openRouter(apiKey: "sk-or-test", model: "x-ai/grok-4")
9+
let request = try await client.buildRequest(messages: [.user("Hi")], tools: [])
10+
let urlRequest = try await client.buildURLRequest(request)
11+
#expect(urlRequest.url?.absoluteString == "https://openrouter.ai/api/v1/responses")
12+
13+
let json = try encodeRequest(request)
14+
#expect(json["store"] as? Bool == false)
15+
#expect(json["include"] as? [String] == ["reasoning.encrypted_content"])
16+
}
17+
18+
@Test
19+
func encryptedContentReasoningSurvivesContinuityReplay() async throws {
20+
let blob = "gAAAAABencrypted-reasoning-payload-1234567890=="
21+
let json = """
22+
{
23+
"id": "resp_enc",
24+
"status": "completed",
25+
"output": [
26+
{
27+
"type": "reasoning",
28+
"id": "rs_enc_001",
29+
"summary": [],
30+
"encrypted_content": "\(blob)"
31+
},
32+
{
33+
"type": "message",
34+
"id": "msg_enc_001",
35+
"status": "completed",
36+
"role": "assistant",
37+
"content": [{"type": "output_text", "text": "The answer is 42."}]
38+
}
39+
],
40+
"usage": {"input_tokens": 10, "output_tokens": 5}
41+
}
42+
"""
43+
let client = ResponsesAPIClient.openRouter(apiKey: "sk-or-test", model: "x-ai/grok-4")
44+
let response = try await client.decodeResponse(Data(json.utf8))
45+
let message = await client.parseResponse(response)
46+
47+
guard case let .object(payload) = message.continuity?.payload,
48+
case let .array(output) = payload["output"],
49+
case let .object(reasoning) = output.first
50+
else {
51+
Issue.record("Expected Responses continuity with a reasoning output item")
52+
return
53+
}
54+
#expect(reasoning["encrypted_content"] == .string(blob))
55+
56+
let request = try await client.buildRequest(messages: [.assistant(message)], tools: [])
57+
let encoded = try encodeRequest(request)
58+
let input = try #require(encoded["input"] as? [[String: Any]])
59+
#expect(input[0]["type"] as? String == "reasoning")
60+
#expect(input[0]["id"] as? String == "rs_enc_001")
61+
#expect(input[0]["encrypted_content"] as? String == blob)
62+
#expect(input[1]["type"] as? String == "message")
63+
#expect(input[1]["id"] as? String == "msg_enc_001")
64+
}
65+
}

0 commit comments

Comments
 (0)