Skip to content

Commit bbea8b8

Browse files
committed
test_responses_create_tool_input WIP
1 parent 4282f65 commit bbea8b8

File tree

5 files changed

+491
-25
lines changed

5 files changed

+491
-25
lines changed

dd-java-agent/instrumentation/openai-java/openai-java-3.0/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ muzzle {
88
pass {
99
group = "com.openai"
1010
module = "openai-java"
11-
versions = "[$minVer,)"
11+
versions = "[$minVer,4.0.0)" // TODO ResponseInputItem.FunctionCallOutput.output type has changed in v4+
1212
// assertInverse = true //TODO fix after module split
1313
}
1414
}

dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/main/java/datadog/trace/instrumentation/openai_java/ResponseDecorator.java

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.openai.models.responses.Response;
1010
import com.openai.models.responses.ResponseCreateParams;
1111
import com.openai.models.responses.ResponseFunctionToolCall;
12+
import com.openai.models.responses.ResponseInputContent;
13+
import com.openai.models.responses.ResponseInputItem;
1214
import com.openai.models.responses.ResponseOutputItem;
1315
import com.openai.models.responses.ResponseOutputMessage;
1416
import com.openai.models.responses.ResponseOutputText;
@@ -54,11 +56,49 @@ public void withResponseCreateParams(AgentSpan span, ResponseCreateParams params
5456
inputMessages.add(LLMObs.LLMMessage.from("system", instructions));
5557
});
5658

57-
Optional<String> textOpt = params._input().asString();
59+
Optional<String> textOpt = params._input().asString(); // TODO cover with unit tests
5860
if (textOpt.isPresent()) {
5961
inputMessages.add(LLMObs.LLMMessage.from("user", textOpt.get()));
6062
}
6163

64+
Optional<ResponseCreateParams.Input> inputOpt = params._input().asKnown();
65+
if (inputOpt.isPresent()) {
66+
ResponseCreateParams.Input input = inputOpt.get();
67+
if (input.isText()) {
68+
inputMessages.add(LLMObs.LLMMessage.from("user", input.asText()));
69+
} else if (input.isResponse()) {
70+
List<ResponseInputItem> inputItems = input.asResponse();
71+
for (ResponseInputItem item : inputItems) {
72+
LLMObs.LLMMessage message = extractInputItemMessage(item);
73+
if (message != null) {
74+
inputMessages.add(message);
75+
}
76+
}
77+
}
78+
}
79+
80+
// Handle raw list input (when SDK can't parse into known types)
81+
// This path is tested by "create streaming response with raw json tool input test"
82+
if (inputMessages.isEmpty()) {
83+
try {
84+
Optional<com.openai.core.JsonValue> rawValueOpt = params._input().asUnknown();
85+
if (rawValueOpt.isPresent()) {
86+
com.openai.core.JsonValue rawValue = rawValueOpt.get();
87+
Optional<List<com.openai.core.JsonValue>> rawListOpt = rawValue.asArray();
88+
if (rawListOpt.isPresent()) {
89+
for (com.openai.core.JsonValue item : rawListOpt.get()) {
90+
LLMObs.LLMMessage message = extractMessageFromRawJson(item);
91+
if (message != null) {
92+
inputMessages.add(message);
93+
}
94+
}
95+
}
96+
}
97+
} catch (Exception e) {
98+
// Ignore parsing errors for raw input
99+
}
100+
}
101+
62102
if (!inputMessages.isEmpty()) {
63103
span.setTag("_ml_obs_tag.input", inputMessages);
64104
}
@@ -67,6 +107,228 @@ public void withResponseCreateParams(AgentSpan span, ResponseCreateParams params
67107
.ifPresent(reasoningMap -> span.setTag("_ml_obs_request.reasoning", reasoningMap));
68108
}
69109

110+
private LLMObs.LLMMessage extractInputItemMessage(ResponseInputItem item) {
111+
if (item.isMessage()) {
112+
ResponseInputItem.Message message = item.asMessage();
113+
String role = message.role().asString();
114+
String content = extractInputMessageContent(message);
115+
return LLMObs.LLMMessage.from(role, content);
116+
} else if (item.isFunctionCall()) {
117+
// Function call is mapped to assistant message with tool_calls
118+
ResponseFunctionToolCall functionCall = item.asFunctionCall();
119+
LLMObs.ToolCall toolCall = ToolCallExtractor.getToolCall(functionCall);
120+
if (toolCall != null) {
121+
List<LLMObs.ToolCall> toolCalls = Collections.singletonList(toolCall);
122+
return LLMObs.LLMMessage.from("assistant", null, toolCalls);
123+
}
124+
} else if (item.isFunctionCallOutput()) {
125+
ResponseInputItem.FunctionCallOutput output = item.asFunctionCallOutput();
126+
String callId = output.callId();
127+
String result =
128+
output
129+
.output(); // TODO ResponseInputItem.FunctionCallOutput.output changed from String to
130+
// Output in 4.0+
131+
LLMObs.ToolResult toolResult =
132+
LLMObs.ToolResult.from("", "function_call_output", callId, result);
133+
List<LLMObs.ToolResult> toolResults = Collections.singletonList(toolResult);
134+
return LLMObs.LLMMessage.fromToolResults("user", toolResults);
135+
}
136+
return null;
137+
}
138+
139+
private LLMObs.LLMMessage extractMessageFromRawJson(com.openai.core.JsonValue jsonValue) {
140+
Optional<Map<String, com.openai.core.JsonValue>> objOpt = jsonValue.asObject();
141+
if (!objOpt.isPresent()) {
142+
return null;
143+
}
144+
145+
Map<String, com.openai.core.JsonValue> obj = objOpt.get();
146+
com.openai.core.JsonValue typeValue = obj.get("type");
147+
148+
// Check if it's a function_call
149+
if (typeValue != null) {
150+
Optional<String> typeStr = typeValue.asString();
151+
if (typeStr.isPresent()) {
152+
String type = typeStr.get();
153+
154+
if ("function_call".equals(type)) {
155+
// Extract function call details
156+
com.openai.core.JsonValue callIdValue = obj.get("call_id");
157+
com.openai.core.JsonValue nameValue = obj.get("name");
158+
com.openai.core.JsonValue argumentsValue = obj.get("arguments");
159+
160+
String callId = null;
161+
String name = null;
162+
String argumentsStr = null;
163+
164+
if (callIdValue != null) {
165+
Optional<String> opt = callIdValue.asString();
166+
if (opt.isPresent()) {
167+
callId = opt.get();
168+
}
169+
}
170+
if (nameValue != null) {
171+
Optional<String> opt = nameValue.asString();
172+
if (opt.isPresent()) {
173+
name = opt.get();
174+
}
175+
}
176+
if (argumentsValue != null) {
177+
Optional<String> opt = argumentsValue.asString();
178+
if (opt.isPresent()) {
179+
argumentsStr = opt.get();
180+
}
181+
}
182+
183+
if (callId != null && name != null && argumentsStr != null) {
184+
Map<String, Object> arguments = parseJsonString(argumentsStr);
185+
LLMObs.ToolCall toolCall =
186+
LLMObs.ToolCall.from(name, "function_call", callId, arguments);
187+
return LLMObs.LLMMessage.from("assistant", null, Collections.singletonList(toolCall));
188+
}
189+
} else if ("function_call_output".equals(type)) {
190+
// Extract function call output
191+
com.openai.core.JsonValue callIdValue = obj.get("call_id");
192+
com.openai.core.JsonValue outputValue = obj.get("output");
193+
194+
String callId = null;
195+
String output = null;
196+
197+
if (callIdValue != null) {
198+
Optional<String> opt = callIdValue.asString();
199+
if (opt.isPresent()) {
200+
callId = opt.get();
201+
}
202+
}
203+
if (outputValue != null) {
204+
Optional<String> opt = outputValue.asString();
205+
if (opt.isPresent()) {
206+
output = opt.get();
207+
}
208+
}
209+
210+
if (callId != null && output != null) {
211+
LLMObs.ToolResult toolResult =
212+
LLMObs.ToolResult.from("", "function_call_output", callId, output);
213+
return LLMObs.LLMMessage.fromToolResults("user", Collections.singletonList(toolResult));
214+
}
215+
}
216+
}
217+
}
218+
219+
// Otherwise, it's a regular message with role and content
220+
com.openai.core.JsonValue roleValue = obj.get("role");
221+
com.openai.core.JsonValue contentValue = obj.get("content");
222+
223+
String role = null;
224+
String content = null;
225+
226+
if (roleValue != null) {
227+
Optional<String> opt = roleValue.asString();
228+
if (opt.isPresent()) {
229+
role = opt.get();
230+
}
231+
}
232+
if (contentValue != null) {
233+
Optional<String> opt = contentValue.asString();
234+
if (opt.isPresent()) {
235+
content = opt.get();
236+
}
237+
}
238+
239+
if (role != null) {
240+
return LLMObs.LLMMessage.from(role, content);
241+
}
242+
243+
return null;
244+
}
245+
246+
private Map<String, Object> parseJsonString(String jsonStr) {
247+
if (jsonStr == null || jsonStr.isEmpty()) {
248+
return Collections.emptyMap();
249+
}
250+
try {
251+
jsonStr = jsonStr.trim();
252+
if (!jsonStr.startsWith("{") || !jsonStr.endsWith("}")) {
253+
return Collections.emptyMap();
254+
}
255+
256+
Map<String, Object> result = new HashMap<>();
257+
String content = jsonStr.substring(1, jsonStr.length() - 1).trim();
258+
259+
if (content.isEmpty()) {
260+
return result;
261+
}
262+
263+
// Parse JSON manually, respecting quoted strings
264+
List<String> pairs = splitByCommaRespectingQuotes(content);
265+
266+
for (String pair : pairs) {
267+
int colonIdx = pair.indexOf(':');
268+
if (colonIdx > 0) {
269+
String key = pair.substring(0, colonIdx).trim();
270+
String value = pair.substring(colonIdx + 1).trim();
271+
272+
// Remove quotes from key
273+
key = removeQuotes(key);
274+
// Remove quotes from value
275+
value = removeQuotes(value);
276+
277+
result.put(key, value);
278+
}
279+
}
280+
281+
return result;
282+
} catch (Exception e) {
283+
return Collections.emptyMap();
284+
}
285+
}
286+
287+
private List<String> splitByCommaRespectingQuotes(String str) {
288+
List<String> result = new ArrayList<>();
289+
StringBuilder current = new StringBuilder();
290+
boolean inQuotes = false;
291+
292+
for (int i = 0; i < str.length(); i++) {
293+
char c = str.charAt(i);
294+
295+
if (c == '"') {
296+
inQuotes = !inQuotes;
297+
current.append(c);
298+
} else if (c == ',' && !inQuotes) {
299+
result.add(current.toString());
300+
current = new StringBuilder();
301+
} else {
302+
current.append(c);
303+
}
304+
}
305+
306+
if (current.length() > 0) {
307+
result.add(current.toString());
308+
}
309+
310+
return result;
311+
}
312+
313+
private String removeQuotes(String str) {
314+
str = str.trim();
315+
if (str.startsWith("\"") && str.endsWith("\"") && str.length() >= 2) {
316+
return str.substring(1, str.length() - 1);
317+
}
318+
return str;
319+
}
320+
321+
private String extractInputMessageContent(ResponseInputItem.Message message) {
322+
StringBuilder contentBuilder = new StringBuilder();
323+
for (ResponseInputContent content : message.content()) {
324+
if (content.isInputText()) {
325+
contentBuilder.append(content.asInputText().text());
326+
}
327+
}
328+
String result = contentBuilder.toString();
329+
return result.isEmpty() ? null : result;
330+
}
331+
70332
private Optional<Map<String, String>> extractReasoningFromParams(ResponseCreateParams params) {
71333
com.openai.core.JsonField<Reasoning> reasoningField = params._reasoning();
72334
if (reasoningField.isMissing()) {

dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/OpenAiTest.groovy

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.openai.client.OpenAIClient
44
import com.openai.client.okhttp.OkHttpClient
55
import com.openai.client.okhttp.OpenAIOkHttpClient
66
import com.openai.core.ClientOptions
7+
import com.openai.core.JsonField
78
import com.openai.credential.BearerTokenCredential
89
import com.openai.core.JsonValue
910
import com.openai.models.ChatModel
@@ -232,32 +233,58 @@ He hopes to pursue a career in software engineering after graduating.""")
232233
.build()
233234
}
234235

235-
ResponseCreateParams responseCreateParamsWithToolInput() {
236-
def functionCall = ResponseFunctionToolCall.builder()
237-
.callId("call_123")
238-
.name("get_weather")
239-
.arguments('{"location": "San Francisco, CA"}')
240-
.id("fc_123")
241-
.status(ResponseFunctionToolCall.Status.COMPLETED)
242-
.build()
236+
ResponseCreateParams responseCreateParamsWithToolInput(boolean json) {
237+
if (json) {
238+
def rawInputJson = [
239+
[
240+
role: "user",
241+
content: "What's the weather like in San Francisco?"
242+
],
243+
[
244+
type: "function_call",
245+
call_id: "call_123",
246+
name: "get_weather",
247+
arguments: '{"location": "San Francisco, CA"}'
248+
],
249+
[
250+
type: "function_call_output",
251+
call_id: "call_123",
252+
output: '{"temperature": "72°F", "conditions": "sunny", "humidity": "65%"}'
253+
]
254+
]
243255

244-
def inputItems = [
245-
ResponseInputItem.ofMessage(ResponseInputItem.Message.builder()
246-
.role(ResponseInputItem.Message.Role.USER)
247-
.addInputTextContent("What's the weather like in San Francisco?")
248-
.build()),
249-
ResponseInputItem.ofFunctionCall(functionCall),
250-
ResponseInputItem.ofFunctionCallOutput(ResponseInputItem.FunctionCallOutput.builder()
256+
ResponseCreateParams.builder()
257+
.model("gpt-4.1")
258+
.input(com.openai.core.JsonValue.from(rawInputJson))
259+
.temperature(0.1d)
260+
.build()
261+
} else {
262+
def functionCall = ResponseFunctionToolCall.builder()
251263
.callId("call_123")
252-
.output('{"temperature": "72°F", "conditions": "sunny", "humidity": "65%"}')
253-
.build())
254-
]
264+
.name("get_weather")
265+
.arguments('{"location": "San Francisco, CA"}')
266+
.id("fc_123")
267+
.status(ResponseFunctionToolCall.Status.COMPLETED)
268+
.build()
255269

256-
ResponseCreateParams.builder()
257-
.model(ChatModel.GPT_4_1)
258-
.input(ResponseCreateParams.Input.ofResponse(inputItems))
259-
.temperature(0.1d)
260-
.build()
270+
def inputItems = [
271+
ResponseInputItem.ofMessage(ResponseInputItem.Message.builder()
272+
.role(ResponseInputItem.Message.Role.USER)
273+
.addInputTextContent("What's the weather like in San Francisco?")
274+
.build()),
275+
ResponseInputItem.ofFunctionCall(functionCall),
276+
ResponseInputItem.ofFunctionCallOutput(ResponseInputItem.FunctionCallOutput.builder()
277+
.callId("call_123")
278+
.output('{"temperature": "72°F", "conditions": "sunny", "humidity": "65%"}')
279+
.build())
280+
]
281+
282+
ResponseCreateParams.builder()
283+
.model(ChatModel.GPT_4_1)
284+
.input(ResponseCreateParams.Input.ofResponse(inputItems))
285+
.temperature(0.1d)
286+
.build()
287+
}
261288
}
262289
}
263290

0 commit comments

Comments
 (0)